How to correctly read the balance and equity of an HL account through the public info API (https://api.hyperliquid.xyz/info). Here, we explain the differences between numbers from different endpoints and UIs, which formula to use for what task (position sizing, risk-cap, display, protective triggers), and how to avoid misinterpreting broken readings as events on the account. Facts are verified with live queries; dates of verifications are noted, and information taken from documentation is marked.
TL;DR
clearinghouseStateprovides data for one dex at a time. Without adexparameter, it returns only the main perp-dex. HIP-3 dex (xyzand other builder-dex) are queried separately withdex: "xyz". Perp equity is calculated as ΣmarginSummary.accountValueacross all dexes; the list of dexes is obtained fromperpDexs. If a dex is omitted, the total is incorrect: an account with active trading on xyz will have its equity underreported by the xyz part, and some capital may be held on smaller builder-dex.- Use
marginSummaryinstead ofcrossMarginSummary. With isolated positions,crossMarginSummaryshows a lower value and reportstotalNtlPos: 0. - Account's capital = Σ perp
accountValueacross all dexes + free spot-stables. Free balance is calculated asmax(0, total − reserve), wherereserve = spotHoldif the field exists, otherwisehold. Stables include USDC, USDT, USDT0, USDH, and USDE. Spot-alt (HYPE, PURR, PIP…), staking, vaults, and HLP are not included: they reside outside ofclearinghouseStateand can be read separately (§1.7). - On Unified Account, spot
holdUSDC mirrors perp margin: hold ≈ Σ perp accountValue + reserve for spot bids. The formula "perps + entire spot" counts the same money twice (×2). Without borrowing, capital equals spottotal. - Portfolio margin (
portfolioMarginEnabled: true):holdgoes negative; actual reserves are inspotHold. Expressiontotal − holdis equivalent to Available Balance from UI but includes borrow capacity, hence the doubling. The linetotal "0.0" / hold "-999999.99"without consideringspotHoldturns into a phantom $1 000 000. - Perp
accountValueis reflexive. On Unified Account, it represents used margin, not capital. Spot ↔ perp swaps move it without any trading, so perp-only cannot be used as a proxy for capital or drawdowns. - Unified Account collateral is fungible across dexes. Per-dex
accountValue/withdrawabledoes not show the capacity of this dex:xyz.accountValuecan often be 0 even with live xyz positions. Margin ratio is calculated against the sum of dexes plus free spot. - Single reading of equity is not a certainty. HL returns syntactically valid but stale or broken responses where equity is vastly lower or tenfold higher than actual. Such responses come in bursts with unchanged exposure, and HTTP 200 without
marginSummarycan occur. What's needed: validation, detector for "equity jumped while totalNtlPos stayed the same," median window of ~7 readings, probation after restarts, and confirmation throughuserNonFundingLedgerUpdates. - Perp and spot should be read at one moment (
Promise.all) and smoothed as a sum, not components. HL switches conventions: spot collateral is inside perp numbers or outside. Only the sum is invariant. - Spot data does not come via WS.
allDexsClearinghouseStatedoes not carry spot balances; they are fetched through REST with cache on minutes. It's acceptable to make a decision without cache (single-flight allowed). After self-fill,clearinghouseStatelags by ~0.5–1 second.
1. Endpoints and Response Forms
Request (POST /info) | Parameters | Weight (Accepted During Planning) | What Gives | In WS |
|---|---|---|---|---|
clearinghouseState | user, dex? | 2 | perp-state of one dex: marginSummary, positions, withdrawable | yes, via allDexsClearinghouseState, but without spot |
spotClearinghouseState | user | 2 | spot-balances: total/hold/spotHold/borrowed/ltv | no (idea about webData2 not verified) |
portfolio | user | 2 (not verified against doc), see "Open Questions" | accountValueHistory/pnlHistory/vlm for 8 windows (vlm — 2026-09-22, §1.3) | — |
userNonFundingLedgerUpdates | user, startTime | 2 or 20 (both estimates are found), see "Open Questions" | deposits, withdrawals, transfers | — |
perpDexs | — | — | list of builder-dexes | — |
frontendOpenOrders | user, dex? | — | open orders (per dex) | — |
allMids | dex? | — | mid-prices (per dex) | — |
userFillsByTime, userTwapSliceFills | user, time | — | closed PnL (closedPnl), two different streams | — |
How many readings are needed: clearinghouseState, frontendOpenOrders and allMids depend on the number of dex, not markets. For two dex (main + xyz) one tick = 2 + 2 + 2 = 6 requests. Full wallet state = 3 info-requests (perp main, perp dex:"xyz", spot), plus allMids for prices. |
1.1 clearinghouseState
// {"type":"clearinghouseState","user":"0xYOUR_ADDRESS"} — main perp-dex
// {"type":"clearinghouseState","user":"0xYOUR_ADDRESS","dex":"xyz"} — HIP-3 dex xyz
{
"marginSummary": {
"accountValue": "…", // equity of this dex, including uPnL
"totalNtlPos": "…", // Σ notional positions of dex
"totalMarginUsed": "…",
"totalRawUsd": "…"
},
"crossMarginSummary": { "accountValue": "…", "totalNtlPos": "…", "totalMarginUsed": "…", "totalRawUsd": "…" },
"withdrawable": "…",
"assetPositions": [
{ "position": {
"coin": "xyz:NVDA", // with HIP-3 already prefixed "xyz:"
"szi": "-1.5", // signed size: >0 LONG, <0 SHORT
"entryPx": "…", // entry price average, may be null
"positionValue": "…", // |notional| by MARK-price
"unrealizedPnl": "…",
"returnOnEquity": "…",
"marginUsed": "…",
"leverage": { "type": "cross", "value": 5, "rawUsd": "…" }, // type: "cross" | "isolated"
"entryNtl": "…" // may not appear
} }
],
"time": 1757000000000
}
- All numbers come in as strings (except for
leverage.value). Arithmetic operations require aNumber()conversion with a check usingNumber.isFinite. - A healthy response always contains
assetPositions, which is an empty array when flat. If the array is missing, reading has degraded and positions cannot be trusted. marginSummarycovers cross + isolated margin, whilecrossMarginSummaryonly includes cross margin. On xyz with isolated positions,marginSummary.accountValueexceedscrossMarginSummary.accountValueby the margin of isolated positions, with cross havingtotalNtlPos: 0. For equity, usemarginSummary, and keepcrossMarginSummaryas a fallback.- Invariant (not from documentation, see "Open Questions"):
accountValue = totalRawUsd + totalNtlPos. Phantom responses (§6) do not violate this invariant, so form validation does not catch them. - Probability invariant:
accountValue ≥ totalMarginUsedin one response. Otherwise, the account would already be liquidated, meaning the response is corrupted. spotState.totalRawUsdis sometimes described as a field in theclearinghouseStateresponse. Its existence and contents are not confirmed (see "Open Questions"). Do not use it as spot balance.
Positions:
- A position is open if
szi ≠ 0andpositionValue ≠ 0. The second condition filters out elements without a real position. - Signed notional =
Math.sign(szi) × |positionValue|. - Current exposure should be calculated based on
positionValue(mark), not|szi| × entryPx. Calculating from the entry point underestimates losing positions and overestimates profitable ones.entryPxis only suitable as a fallback. - The leverage of a coin (
leverage.value) can only be seen during an open position: for a flat wallet, the coin's leverage cannot be determined from the API. entryPx— average entry price for the position.- ROE:
unrealizedPnl / marginUsed≡(mark − entry) / mark × leverage(for longs, reverse the sign for shorts), becausemarginUsed = positionValue / leverage, andpositionValueis calculated based on mark. The denominator is mark, not entry. With 4x and +10% prices, the entry formula gives 40%, while the correct value is 36.4%. - Overall PnL% for the account =
Σ unrealizedPnl / Σ marginUsed.
1.2 spotClearinghouseState
// {"type":"spotClearinghouseState","user":"0xYOUR_ADDRESS"}
{
"balances": [
{ "coin": "USDC", "total": "…", "hold": "…", "entryNtl": "0",
"spotHold": "…", // only on portfolio-margin / unified accounts; the actual reserve
"borrowed": "…", "ltv": "0.0" }
],
"portfolioMarginEnabled": true // not always present
}
total— the entire token balance,hold— reserve. On a regular account, the reserve is for open spot orders; on unified, it's also for perp margin. On PM-account,holdis net “reserve − loan capacity”, and can be < 0.total/holdare expressed in token units, whileentryNtlin USD. For stablecoins,entryNtl = "0", not null, so the codeentryNtl ?? totalreturns 0 and loses all USDC.- One aggregated line comes to the token. A duplicate stablecoin means that the spot deposit is fully unreliable: otherwise, equity would be overstated and positions resized.
- Spot balances lie outside perp response.
clearinghouseStatedoes not contain them, nor do WS.
1.3 portfolio
{"type":"portfolio","user":…} returns an array of pairs [windowName, { accountValueHistory: [[ts, value], …], pnlHistory: [[ts, value], …], vlm }]. There are eight windows: day, week, month, allTime, perpDay, perpWeek, perpMonth, perpAllTime. The third key of each window is vlm (window volume): a live read-only query on 2026-09-22 (zero address) showed that all eight windows had exactly accountValueHistory, pnlHistory, vlm; the previous description knew only two keys.
- The
allTimecurve starts from the account start ("0.0"). perp*-windows aggregate perp-equity across all perp-dexes. Confirmed: perp-total inportfolio= mainaccountValue+ xyzaccountValueto dollar. This allows for easy verification of per-dex sums.- On Unified Account, the last value of
allTime.accountValueHistoryequals full capital = spottotal= perp + free spot. - PnL should be taken from
pnlHistory, not deltaaccountValueHistory: deposits raise balance without profit. For week/month/allTime, use the last point inpnlHistory. If the response format is incorrect, returnnull(no data), not 0. - A live read-only query on 2026-09-23 07:26 UTC (zero address and a randomly generated memory address):
- Each window's
pnlHistorystarts with"0.0": this is PnL from the start of the window, not accumulated since account start; - The timestamps in
accountValueHistoryandpnlHistoryfor each window are identical; - The last point is roughly a minute before the query (live tail), with window spans: day 24 hours, week 171 hours, month 728 hours;
- For zero address, points count: day 36 (step ≈ 42 minutes), week 65, month 51, allTime 111; values are strings with 1–10 decimal places,
vlmis a string; - An address without history gets not empty series but 11 evenly spaced
"0.0"points in each window andvlm: "0.0", HL draws the grid. One such curve cannot distinguish an “empty account” from “no data”.
- Each window's
1.4 userNonFundingLedgerUpdates
{"type":"userNonFundingLedgerUpdates","user":…,"startTime":ms} → [{ time, hash, delta: { type, usdc?, amount?, … } }].
- Encountered
delta.type:withdraw,accountClassTransfer(spot↔perp),send(including between dex),rewardsClaim,subAccountTransfer(master ↔ sub; sign of flow — more details in fills-and-history.md §12). - The direction of
send/transfer is encoded differently. If only the response "does the ledger explain the shortage" is needed, use|usdc ?? amount|without distinguishing types. - Deduplication by
hash. After a restart, an inherited session would otherwise shift the base again. It's sufficient to store the last few hundred hashes (e.g., 500). - Incoming HL Send (transfer from another HL-address) is credited to spot.
1.5 Iteration over dex
- The list builder-dex is taken from
{"type":"perpDexs"}. Hardcoded['', 'xyz']is broader than a naive single request but not complete. - If you report the full equity of an address, sum all dexes. If you assess what your code will see, sum only the configured dexes and explicitly state which ones it does not see.
- Completeness check: The number of open orders via API should match UI HL. A discrepancy indicates a missing dex. Typical scenario: The wallet appears flat on the main dex, but orders and positions are on xyz.
1.6 WS
allDexsClearinghouseStatecarries perp main + xyz (accountValue, marginUsed, positions), but not spot. Spot-stables are fetched via a REST request with a long cache.- Inline
spotState.totalRawUsdfrom the WS snapshot as a spot balance should not be used: it silently holds alt tokens. - If storing
totalValueonly from the WS message, each update will erase the spot part of the reported balance. Correctly keep the last RESTspotValueand insert it into every WS update:totalValue = perpValueWs + lastRestSpotValue. - Sanity WS Snapshot: If positions exist but Σ
marginUsed(main + xyz) = 0, then the payload did not conveytotalMarginUsed. A position always holds margin. This snapshot is not suitable for risk-cap (ratio would be 0), requiring a switch to REST. - Freshness of WS → REST: If the snapshot is older than ~90 seconds, go to throttled REST
clearinghouseState.
1.7 Balance by Account Type
First determine the type of address through {type:"userRole", user} → missing | user | agent | subAccount | vault (more details in accounts.md).
| What is input | How to read | Pitfalls |
|---|---|---|
| Main account | §3: Σ perp accountValue across all dex + free spot-stables | — |
| Sub-account | Same queries with user = sub address. Each sub has its own margin, positions and spot | Equity of master subs not included. "Total capital of owner" = master + Σ subs |
| All subs of master | {type:"subAccounts", user: master} → list { name, subAccountUser, master, clearinghouseState, spotState } (format as per documentation, not verified). If no nested states, read each subAccountUser separately | Nested snapshot may cover only the main dex. HIP-3 sub dex read separately with dex |
| Vault address | clearinghouseState/spotClearinghouseState with user = vault address — this is equity total of vault. {type:"vaultDetails", vaultAddress} → leader, depositors, portfolio; for regular account response null | Share of depositor ≠ equity of vault |
| Share of own funds in vault/HLP | {type:"userVaultEquities", user} → [{ vaultAddress, equity }] (as per documentation, not verified) | These funds not included in clearinghouseState and capital for sizing |
| Agent / API wallet address | userRole → agent, account address in data.user. Balance read from it | An agent address has no own funds: "$0" here means incorrect input, not an empty account |
| HYPE staking | {type:"delegatorSummary", user} (as per documentation, not verified) | Outside of perp and spot; does not enter capital for sizing |
| Rules: |
- For sizing and risk-cap, only the trading capital of the main or sub account that trades is considered (not vault deposits, HLP, or staking): these cannot be withdrawn instantly as margin.
- When displaying "owner's total capital," print the breakdown: master / each sub / shares in vault / staking. Otherwise, the sum cannot be verified.
- For the owner's capital,
master ↔ sub(subAccountTransfer in ledger) is not a deposit or withdrawal but for the sub session it is a cash flow: the base of the sub session is shifted by the transfer amount (see fills-and-history.md §12).
2. Account Modes: What Do the Numbers Mean
| Classic (legacy) | Unified Account | Portfolio margin | |
|---|---|---|---|
| Where USDC under perps | in perp leg (Spot→Perps transfer) | in spot; stablecoins USDC/USDT/USDT0/USDH/USDE serve as cross-margin | in spot, plus borrowable capacity |
Perp accountValue | money in perp account | used margin (positions + liquidation orders), not capital | same |
Spot hold | reserve for spot orders | ≈ Σ perp accountValue + spot bid reserve | < 0 (reserve − borrowable capacity); actual reserve in spotHold |
| Capital | Σ perp av + free spot | Σ perp av + free spot = spot total (without borrows) = portfolio.allTime | Σ perp av + max(0, total − spotHold); total = spotHold + supplied(Earn) |
| Trap | spot total ≈ 0 with large perp capital | "perps + all spot" = ×2 | total − hold = Available UI = ×2; USDT0 hold -999999.99 |
| Details: |
- Unified: spot backs perps without a transfer. Verified on 2026-09-14: with a zero perp leg and USDC only in spot, a post-only order was accepted. An internal spot↔perp transfer in this mode is neither a deposit nor a withdrawal and must not move the session baseline.
- Unified: USDC flows between the spot ledger and perp
accountValueas margin usage changes. For example, an allocation of "perp — about half, spot — rest" may shift to "perp — almost all, spot — close to zero" with a constant total (conditional shares). Therefore, your equity always sums both legs. - Anatomy of
spotHoldUSDC (verified down to the dollar live, 2026-08-30):spotHold = Σ perp-equity across all dex + reserve for pending spot-bids,total = spotHold + supplied(Earn). Thus, free =total − spotHold= exactly Earn-balance. The difference "spotHold − perps" was constant throughout the day: this is a spot-order reserve, not a reading error. The spot-bid reserve does not enter the denominator: if a bid fills, USDC becomes a spot-asset (spot-assets are also not counted), and the denominator does not fluctuate from spot-fills. - Identity "reserve ≈ perps" holds to within fractions of a percent; noticeable deviations are explained by the spot-bid reserve (verified on multiple wallets).
- PM-account with zero Earn:
spotHoldbitwise equalstotal, no free spot, denominator = only perps.totalremains as a reference "own funds". - Classic and Transfers:
usdSendfrom a legacy account sends from Perps without the spot balance noticing. An incoming Send falls into spot, so without Spot→Perps withdrawal hits insufficient balance. On unified, spot-to-perp sweep is not needed. - Important to Check Mode for Formula Selection: "spot total as capital" breaks on classic (USDC in perp leg, spot empty: B ≈ 0 when A is large) and swells with the reserve under spot-limits (B much larger than A).
3. Formulas
3.1 Perp Equity
perpEquity = Σ_dex Number(clearinghouseState{user, dex}.marginSummary.accountValue) // dex ∈ perpDexs, "" = main
3.2 Free Spot-Stables
STABLES = {USDC, USDT, USDT0, USDH, USDE} // configurable: new stablecoins may appear
reserve = (spotHold is non-empty string) ? spotHold : hold
free = Σ_{coin ∈ STABLES} max(0, total − reserve)
Validation rules (fail-closed, spot fails completely):
- `total ≥ 0` and `reserve ≥ 0`. Numbers are parsed strictly: `" 100"` and `"abc"` — fail.
- Negative `hold` is allowed **only** if `portfolioMarginEnabled === true` **and** `spotHold` exists.
- `hold > total` — fails only on a regular account. On PM, reserve can exceed total; then the deposit line clamp is set to 0 but spot does not fail.
- Duplicate stablecoin, absence of array `balances`, non-object response — fail. Empty `balances: []` — valid zero.
- **Do not use** `total − max(0, hold)`. See pitfalls §10.
3.3 Capital (Equity for Sizing)
capital = perpEquity + free // both terms from ONE reading moment
capitalSmoothed = median(window of last 7 trusted capital readings)
Do not add: spot total (hold is already included in perp), spot-alts on the market (volatility will distort drawdown metrics: an alt -40% would look like a liquidation), staking, vaults, HLP, uPnL above (it's already inside accountValue).
Separately about uPnL: accountValue includes unrealized PnL. Therefore, sizing from equity is automatically reduced on drawdown, and this is correct: the base is equity, not free cash.
3.4 Margin Ratio of Unified Pool
marginUsed = Σ_dex marginSummary.totalMarginUsed
accountVal = Σ_dex marginSummary.accountValue + freeStables
marginRatio = accountVal > 0 ? marginUsed / accountVal
: (marginUsed > 0 ? +Infinity : 0) // fail-closed: near-liquidation blocks entry
- Per-dex ratio (
xyzMarginUsed / xyzAccountValue) is only allowed as informational. Do not cap it with the limit: USDC physically usually resides on main,xyz.accountValue = 0, leading to a false block of entry. - Documentation from HIP-3 justifies this: «For USDC HIP-3 positions, collateral comes from your USDC (Perps) available balance». The front end HL also shows one number «Account Equity»/«Margin Usage» for the entire account.
- If you divide only by perp
accountValuewithout spot-stables, the ratio is hyper-inflated on accounts with capital in spot-USDC. Any entry is blocked by a ceiling, even though HL order allows it: perpaccountValuecan be notably less than the real balance. - The naive
accountValue ≤ 0 → ratio 0allows entry at the point of liquidation:0 >= capgives false.
3.5 Which Number for Which Task
| Task | Formula | Why Not Another One |
|---|---|---|
| Account Capital, Sizing | Σ perp av (all dex) + free stables | robust to account mode; measures perpetual risk-capital |
| Risk-Cap Denominator (Margin Ratio) | same + fail-closed +Inf | single collateral pool |
| Perpetual Capital Drop of the Account | only Σ perp av (all dex), with likelihood gating | changes on PM spot (Earn, spot-bids) to large amounts unrelated to trading |
| Unified "Balance as on Exchange" Display | Σ spot stables total (free + hold), without uPnL | matches trade.xyz "Total Equity"; captures USDC under isolated positions |
| Realized/Periodic PnL | portfolio.pnlHistory | delta equity includes deposits |
| Session Loss/Capital Drawdown Ceiling | equity, adjusted for ledger-flows | otherwise read as a loss |
3.6 External "Balance Numbers" and Their Relationships
| Number | Includes | Suitable For |
|---|---|---|
clearinghouseState(dex).marginSummary.accountValue | equity of one dex incl. uPnL; on unified — used margin | per-dex diagnostics |
portfolio perp* | Σ perp across all dex | per-dex summation verification |
portfolio allTime (latest point) | full capital | capital reconciliation |
Leaderboard accountValue | "perps of all dex + free spot" (not perp!) | valuation/warning |
| HL frontend "Total Equity" | + pledged collateral + market spot-alt | not for sizing: may significantly exceed formula §3.3 |
| trade.xyz "Total Equity" | = spot USDC total (free + hold) on unified; "Unrealized PNL" row is reference, not a component | display |
| trade.xyz "Trading Equity" | close to perp + free stables, but in verification was lower than the perp + xyz + free formula | guideline; exact match is not the goal, important is one formula throughout the code |
| UI "Available Balance" | total − hold, on PM includes borrowing capacity | not for capital |
4. Per-dex Equity and dex Capacity
- There is no separate "dex capacity" on Unified Account. Verified with fill from 2026-08-28: IoC-order passed to main at
accountValueof this dex, which was many times smaller than the order's notional. It was margined by free spot. - Per-dex utilization ceiling (division by
availablefor a specific dex) on Unified Account cannot be built: it falsely removes and blocks orders for hours when there is actually available margin. accountValueof a specific dex drops to zero with an active account when margin is used by another dex. Per-dexwithdrawable/accountValueis not a measure of trading capacity.- At the same time, per-dex summation is mandatory for equity, and a missed dex makes the total invalid: if most perp-equity lies on xyz, without a request with
dex:"xyz", capital is undervalued by multiples. - Opposite model: each HIP-3 dex has its own margin account with its own USDC (transfer to dex separately), and then equity and exposure ceiling are calculated per dex. This behavior is likely without Unified Account / DEX abstraction. See "Open Questions".
5. Reflexivity perp equity
- Any value scalable from the perp-equity account is reflexive to spot↔perp transfers: without any trading
accountValuegrows or falls due toaccountClassTransfer. - Unified Account perp→spot transfer does not change the spot
totalby a cent. Only the free/hold distribution changes. Verified on 2026-09-07: perps X → 0, spottotaldid not shift bit-by-bit, free increased exactly by X, capital matched to the cent. - Invariant:
capital = perps + free_spot = spot.total(without borrows). The perp↔spot transition changes onlyhold. - Drop in perp-equity ≠ loss and ≠ withdrawal. Verify sources:
- Loss —
userFillsByTime(closedPnl) anduserTwapSliceFills(two different streams); - Withdrawal/transfer —
userNonFundingLedgerUpdates(withdraw,accountClassTransfer,send); - Full list of position changes sources: fills + TWAP slice fills + liquidations + main/sub-account/vault transfers.
- Loss —
- Visible drop in perp-equity may not be confirmed by fills (
closedPnlover a period) or the ledger: the point of reference is a stale snapshot from lagging replica. Do not build stories on "drawdown" or "transferred to spot" without sources. - Perp↔spot transfers appear as deep "drawdown" for perp-only metrics and may trigger false latching with market closure, though fills and ledger do not show any loss or withdrawal.
- Two witnesses of rollover: spot
totalblind to the mirror release on unified (perps→spot), spotfreeblind to money going into spot-bids (they go to reserve). Take the maximum increase from two: these are two views of one sum. For money really to leave the account, both values must fall. Guard watching onlytotalgives a false latch. - Before believing "withdrawal" latch: add perps + free spot at peak and now. Matched — means rollover. Check consistency of saved triplet:
spotTotalAtPeak − spotFreeAtPeakmust matchperpAtPeak. - One number should not answer two questions. "What is the size" (denominator, spot needed) and "has he blown up" (guard, spot harmful) — different metrics. On PM-account, drawdown threshold from peak may lie between spot and perps: then rollover from Earn to spot-bids reads as deep drawdown and closes positions by market, while full perp drain leaves spot above the threshold, and guard misses.
- Freshness of perp-leg for guard is independent of spot freshness. Spot endpoint failure should hold sizing (spot in denominator), but not disarm protection that does not use spot.
6. Accuracy of Reading
6.1 Observed Distortions Classes
| Class | Looks Like | How to Catch |
|---|---|---|
| Degraded 200 | HTTP 200, but no marginSummary/accountValue (under 429/5xx-storm) | absence of field = defect, not $0 |
| Phantom Down, Isolated | av dex drops by orders of magnitude, totalNtlPos changes negligibly (hundredths of a percent), av == totalMarginUsed, cross → 0 | detector "equity jumped with unchanged exposure" |
| Phantom Down, Cross | av main drops by tens of times, totalNtlPos changes negligibly, cross repeats av, av ≠ totalMarginUsed | same detector. Detector tied to isolated case features will miss this class |
| Phantom Up | av is many or dozens of times higher with the same exposure on main and xyz | symmetric detector |
| Lagging Replica | snapshot with a position long closed by fills (hours and more); equity snapshot differs noticeably from real one | median window; events only via ledger/fills |
| Convention Mismatch | perp and spot from different moments/conventions; component medians give phantom shift in sum | read using one moment, smooth the sum |
| Spot Without Reserve (medium) | hold: "0.0" without spotHold: entire balance looks free; would have given ×2 on PM | peak anchor — median window with time span |
| Broken Numbers | szi is not a number, duplicate coin between dex, szi≠0 && leverage≤0 | whole read degraded |
| No Margin for Positions | WS-snapshot: positions exist, Σ marginUsed = 0 | do not trust snapshot, go to REST |
Phantom Properties (Sampler 2026-08-24/25): episodes repeated within a day, equity in them is much lower than real, and one position looked like it had eaten almost the entire account. Bursts approximately every 15 minutes, each shorter than a minute (30–60 seconds). Values are stable to the cent. None of the "shape" detectors trigger: both dex return correct marginSummary. Print dex breakdown in the episode log (av, ntl, marginUsed, cross, number of positions): the reason for the final sum cannot be figured out. |
6.2 Validation Response Checklist (fail-closed)
clearinghouseState (for each dex):
marginSummaryexists andaccountValueis finite. Absence means degradation, not an honest $0.assetPositions— array.accountValue ≥ totalMarginUsed(NaN in any = defect, tick skipped).- Each position has finite
szi,entryPx,positionValue,unrealizedPnl. Forszi ≠ 0,leverage.value > 0. - No duplicates of
coinbetween dex snapshots. - Non-finite
szi= corrupted read, not "no position".Number(undefined)= NaN,Math.abs(NaN) > 0= false: the coin is read as flat, and the bot market closes a live position.spotClearinghouseState: see §3.2.
Flag semantics:
okmeans that the request was successful, not that the value is non-zero. The latch "xyz.accountValue === 0→ error" masks wallets with actually withdrawn capital (the last cache holds an old balance) and does not allow the balance to decrease.- Full reading of all dexes with
accountValue = 0— a legitimate empty account. The "last good" cache should be deleted in this case. Otherwise, the sequence 100 → 0 → error will return 100 withok = true. - Separate flags:
positionsOk— positions map is full;ok— entire read is trusted (positions + equity);equityFresh— equity was taken from this response (not from cache), dex is observed for the first time, probation passed. Code operating on "flat" must checkpositionsOk. Cached equity is sufficient for holding defenses and ceilings but not for authorizing position growth: capital withdrawal could have occurred during an outage.
6.3 Improbable Equity Detector (per dex)
exposureHeld = |ntl − prevNtl| ≤ 0.10 × prevNtl
implausible = exposureHeld && (av < 0.75 × prevAv || av > 1.33 × prevAv)
prevAv == 0 || prevNtl == 0 → not defective (first tick after restart)
Logic: PnL moves equity and notional consistently, while an honest entry/exit changes notional. Equity that jumps while exposure remains static can only be explained by a movement of funds (see the ledger) or a corrupted reading. This detects a change, not a shape: a stable account containing only isolated positions (av == marginUsed, cross 0) for two consecutive ticks is not defective.
Test specification:
| Case | Verdict |
|---|---|
| av −20%, ntl unchanged | ok (price movement; boundary ~25%) |
| av +19%, ntl unchanged | ok |
| av ×48, ntl unchanged | defect |
| av and ntl fell together | ok (real exit) |
| ntl −50%, av −84% | ok (partial exit) |
| av ×48 and ntl ×5 | ok |
| prevAv = 0 or prevNtl = 0 | ok |
| stable isolated-only account | ok |
| Limitations: |
- The detector must remain silent during exposure change (the trade is being executed), and this moment is when the ghost passes through. Therefore, a median filter is needed on top of it (§6.4).
- Unified Account per-dex detector does not suffice. HL convention alternates (§6.5): the detector resets with clean readings, fails to reach the safeguard, repeats alerts, and holds an upward trend for parts of ticks.
- Alternative threshold: defect if
|av − medianAccepted| > |positionUsd| × 0.02 + max(5, 0.01 × median), wheretotalNtlPoshas shifted less than 1% and there were no fills since the last snapshot.
Likelihood gate before protective triggers:
distrust = perpEquity < ref × (1 − dropPct) && Σ|positionValue| > exposureRef × (1 − dropPct/2)
ref = peak of perp-equity; if no peak — RECENT maximum
exposureRef = recent maximum exposure; otherwise, all-time peak
- The gate should be **above** all triggers because they all divide by equity. A low value looks like both "emptied out" and "one position ate the entire account".
- The reference is the recent maximum, not the all-time: after a large account unwind, an all-time peak would make the gate dead forever.
- Without a peak (cold start, formula change, recently cleared latch), the gate should rely on the recent maximum of perp-legs. If one variable requires `peak > 0` and serves as both a gate and trigger, then after clearing the peak, the trigger works without protection from ghosts for some time.
- Debounce danger: N ticks in a row **of the same type** (equity drop | "one position ate the account"). Alternating types of dangers are jittery readings; they do not accumulate into a latch. The output is not "undone" after 15 seconds, so waiting does not cost anything. The latch can only be cleared manually without auto-recovery.
6.4 Window Median
- Equity on which the code relies — median of the last 7 readings (for unified-account — median sum of perp from all dexes + free spot, §6.5). "Truth repeats itself, phantom flickers": single and double distortions do not enter the median.
- Unverified reading does not replenish the window and returns the previous median. The first verified reading for a new wallet returns itself. No window means unverified reading → 0.
- Example (hypothetical numbers):
[100, 101, 4050, 100, 4850, 99, 100] → 100: single and double phantom values do not enter the median. - Price: real changes in equity reach a lag of ~1 minute (e.g., with 2 readings within a 15-second tick, the value should hold for ~50 seconds). Positions remain raw without any lag.
- Without a median, phantoms are stored in the database during exposure change (the detector is silent at this moment), after which healthy readings are rejected by the storm and the balance display fails.
- Smoothing needs to be applied to the quantity that is later compared with the peak, using a separate window (perp leg separately from capital). The spot counterpart of the peak should also be smoothed with the same window and formula; otherwise, the pair "median perp + raw spot" will come from different moments.
- Manual verification: read consecutively several times and take the median. Comparing two raw snapshots proves nothing; they could have arrived from different replicas.
6.5 Change of HL Convention and Instantaneous Reading
- Between replicas, the Unified Account representation "swims": spot collateral sometimes enters the perp
accountValue("all collateral in perps"), and sometimes is given separately ("spot separately"). The alternation between "perp — small share, rest free spot" ↔ "perp — almost entire capital, free spot near zero" was observed with one sum. Main-dex was also read as part of either convention. - Both conventions are legitimate; only the total amount "all perps + free spot" is invariant.
- From this, two rules follow: (1) read perp and spot at one moment (
Promise.all), (2) smooth the sum. Stitching perps from one reading with a spot from another gives a phantom shift in the sum.
6.6 Trial of Cold Start
- The first 3 minutes after the wallet is observed by the process,
equityFresh = false. Position growth is held back, take profits and exits work, balance display is marked as unverified. - During probation, per-dex readings are accumulated (up to 60). At the end, the plausibility baseline is the median of the collected readings, not the first or last reading. A median from ~12 readings tolerates up to half of them being corrupted.
- If a restart coincides with a phantom burst, the phantom becomes the baseline: healthy readings will be rejected as a "suspicious drop," and the balance will show the cached phantom value. This is why probation and the median are used.
- The duration of the trial is three times longer than the longest burst (<60 seconds).
- An unverified reading outside the trial means that the reading for this tick is unreliable. Requery after 15–30 seconds, no need to fix it.
6.7 Episode of Suspicion and Ledger (by Time, not by Counter)
| Parameter | Value | Purpose |
|---|---|---|
| Request ledger | 2 minutes after the start of the episode | phantom bursts are shorter |
| Re-request ledger | every 3 minutes, startTime = since − 15 minutes | |
| Alert person | 10 minutes later, one per episode | |
| Accept as honest drop | 30 minutes later | safety net |
| Ledger explains | Σ|usdc ?? amount| records with time ≥ since ≥ 50% shortfall | foreign dust should not "explain" the drop |
- While the episode is ongoing, equity of suspicious dexes does not go into the sum; cash is taken instead. After TTL of cash, the bot holds positions and take profits and does not grow.
- Why not counter. The rule “3 suspicious readings — we believe” gets burned out in ~30 seconds per one episode when read frequently, faster than a phantom burst ends, and closes the position on the market.
- Alternative: the same "impossible" value (within
max(0.5, 0.002 × median)) is accepted as fact 3 times in a row (deposit, withdrawal), with the median ring reset. Without this, deposits would forever make all withdrawals "unbelievable". Disadvantage: it can miss systematic formula error (§10, para. 28). - Financial halt (loss/drawdown) requires 3 confirmations in a row and fresh ledger: before the halt, the ledger is read forcibly.
6.8 Peak for Protective Triggers
- A burst of several elevated responses raises the peak forever, with a valid formula stamp, silently.
- Distorted readings come in batches, so "10 probes in 10 seconds" = one probe. The hump and re-basing of the peak require both the number of probes and time span: the candidate must stay above the old peak by median ≥ 7 probes and ≥ 10 minutes. A drop below the old peak by one tick resets the window.
6.9 Policy on Degradation
- Last-good equity per wallet, TTL 10 min. Written only with a positive trusted reading. Degraded readings take from cache. No cache →
ok = false, skip the tick. - A fallen xyz-fetch (422/429/network/"no xyz-account") should not give 0 in sum. Hold last-known xyz. Otherwise, the portfolio is deflated for a cycle and derivatives blow up.
- Spot reading failure should not drop the tick. Return
{sum: 0, ok: false}, do not throw. Throwing insidePromise.allof snapshot rejects the whole snapshot along with guard-closures and TP servicing, even if perp readings are in order. - But 0 instead of spot is not always conservative. For risk-cap denominator, 0 gives a higher ratio and extra entry lock, which is safe. If position size is calculated from equity, 0 deflates it, and the bot may shrink its position on market. Properly substitute last-known-good stablecoins with "not fresh" tag. If such value does not exist, equity is untrusted and no positions are reduced by it.
- A
clearinghouseStatefailure of one dex may not throw, but resolve topositionsOk = false. Coins look flat, and code that resolves "position absent" will open a duplicate and remove protective TP. When!positionsOk, skip the whole account on tick. - No trusted equity → nothing is opened. No live or saved last-known-good equity — no new positions are opened.
- Unknown balance in display does not block anything (real gates work by live
accountValue). In write-through to DB never write null: a HL failure should not overwrite the last value and look like "balance dropped".
6.10 Formula Stamp Next to Memorized Number
- A number from the past (peak, baseline, initial balance) is comparable with today's reading only if both are calculated by one formula. Otherwise, a peak recorded using a doubled formula gives a false "decline" → latch and market position closure, while the monitor reporting after enabling spot in the formula indicates a "capital growth," even though the formula has increased.
- Treatment: Store the formula stamp next to the value (version, is spot enabled, list of dex). There are three outcomes:
- The stamp matches — compare;
- Another one — not comparable, decision not taken;
- No stamp (legacy) — same outcome. Change the spot parser, capital formula, or smoothing — bump the version with the same commit.
- Conservative Portfolio: An old peak can be used when switching bases only if the old formula for the same moment gives a number no greater than the new one (spot is added only, dex set is expanded). Any other pair (capital peak against perp-only) is comparable only at exact equality.
- Immediately after deployment, recalculate all saved bases (initial balance and so on) with a forced rewrite when changing the formula for equity. A regular update "only if NULL" will not rewrite anything, and derived relationships from the balance become garbage.
- Update the baseline for equity alerts "sudden equity jump" even when alerts are disabled. Otherwise, after resuming operation, all quiet windows will report one phantom jump. Ignore jumps below the threshold; repeat the alert only on a new level.
- All modules responding to one question should call the same function for equity. A module with a perp-only denominator matches the main formula as long as the free spot balance equals 0; when a significant part of the capital moves into the spot, it shows phantom divergence
1 − perpOnly/capital, i.e., exactly the share of the spot. Handle degradation of spot reading in such a module fail-closed: do not judge.
7. Caching and Freshness
| What We Read | Policy | Why |
|---|---|---|
| Resolution of order (risk-cap, "is there already a position") | no cache; single-flight parallel calls | burst orders = dozens of identical clearinghouseState for one wallet in seconds; the result would be the same anyway |
| Positions for UI | 30s cache; invalidation on fills of own orders and manual closings; TTL covers only manual edits in UI HL | without cache, each render = round-trip |
| Balance display in UI | initial WS snapshot; older than 90s → throttled REST | to reduce REST load |
| Spot-stables (denominator for risk-cap) | SWR, TTL of the order of dozens of minutes (e.g., 30 min); for UI balance display — blocking fresh request if cache is older than 60s | spot stables change only with deposits, withdrawals, and settlements; without cache, these reads are the main source of 429 errors, and their weight increases linearly with the number of accounts |
| Dashboard with frequent polling | SWR every 60s, in-flight Promise deduplication on error, last value if failed | |
| Financial operations (sweep, transfer) | live read, no cache | |
| Last-good accountValue | TTL 10 min (§6.9) | degraded 200s |
| Ledger-streams for session | every 30s + forced before monetary halt | |
Lag After Order. clearinghouseState (and WS-snapshot) shows the position after its fill with a delay of ~500 ms, in other observations 0.5–1 s. Consequences: |
- Two quick entry decisions (< 500 ms) both see "no position" and open another position — the position is duplicated. Mutex on (account, token, side) serializes but does not add freshness. For an entry decision, trust your local record (
positionExists = haveOnHl || localRecord), closure resolve by HL. - Re-read immediately after
reduceOnlyshows a closed position → false "failed to close". Closure indicator — local record, deleted on fill. If re-reading HL, then aftersleep(1200). - The truth about the position is in
clearinghouseState, not local DB. This covers manual closure in UI. If no positions on HL but a local record exists, do not send order, delete record (self-heal). - Position between REST snapshots can be tracked by WS
userFills. REST snapshot accepted only if it matches the evaluation "snapshot + fills after" with tolerance10^-szDecimals. Same unsynchronized snapshot is retried (WS missed event). Position older than 15 s — reason for pause.
429 from REST-fallback. If during a stale WS-snapshot each trading decision, balance request from UI and periodic sweep go to full REST (3 info + allMids), there are bursts of hundreds of reads per minute. Fixed by SWR-cache of spot, single-flight, and WS-first read.
Address Registration. Cache keys and comparisons — address.toLowerCase(): HL may return user in checksum- or lower-case.
8. PnL, Cash Flows, History of Equity
- Periodic PnL —
portfolio.pnlHistory(the last point in the window). Change in balance snapshots — a separate value that includes deposits. The report should distinguish betweenpnl(exchange; null if none) andchange(snapshots) and always returnsince, indicating when the history started. Otherwise, on the second day, it would show "0% gain for the month". - Daily equity snapshots: key by UTC date (
toISOString().slice(0, 10); server's local timezone does not affect). The last value of the day = snapshot. Degraded reads (equity ≤ 0) are not written. Storage duration — e.g., 400 days. If the snapshot collector was down, take the "nearest not later than date". - Loss ceiling and session drawdown — from trading, not withdrawal. The session base and peak shift by
deltaUsdledger flows since the start of the session, deduped byhash. On unified, internal spot↔perp conversion does not move the base. Otherwise, withdrawals are read as losses, triggering a false halt with market closure. - PnL for closed trade: main path —
entryPxposition × actual fill (without REST and without race). Fallback —unrealizedPnlposition, read before reduceOnly-order (first WS-snapshot, otherwise one REST). Position not found (read lost the fill race) → PnL unknown. - Equity-curve of a strategy can be built from its own closed trades with PnL, without HL queries.
9. TypeScript Snippets
9.1 Full Account Reading (SDK @nktkas/hyperliquid 0.27.x)
import * as hl from "@nktkas/hyperliquid";
const transport = new hl.HttpTransport();
const info = new hl.InfoClient({ transport });
const DEXES = ["", "xyz"]; // "" = main perp-dex; full list verify with {type:"perpDexs"}
const STABLES = new Set(["USDC", "USDT", "USDT0", "USDH", "USDE"]);
export async function readAccount(user = "0xYOUR_ADDRESS") {
// perp for all dex and spot — in one moment (HL switches unified-account convention between reads)
const [spotRaw, ...chs] = await Promise.all([
info.spotClearinghouseState({ user }),
...DEXES.map((dex) => (dex ? info.clearinghouseState({ user, dex }) : info.clearinghouseState({ user }))),
]);
const perDex = chs.map((ch, i) => parseClearinghouse(ch, DEXES[i]));
// spotHold / portfolioMarginEnabled may be absent in SDK types — read as unknown
const spot = parseFreeStables(spotRaw as unknown);
const positionsOk = perDex.every((d) => d.ok);
const perpEquity = perDex.reduce((s, d) => s + d.accountValue, 0);
const marginUsed = perDex.reduce((s, d) => s + d.marginUsed, 0);
const capital = perpEquity + (spot.ok ? spot.free : 0);
const marginRatio = capital > 0 ? marginUsed / capital : marginUsed > 0 ? Number.POSITIVE_INFINITY : 0;
return { perDex, perpEquity, spot, capital, marginUsed, marginRatio, positionsOk, trusted: positionsOk && spot.ok };
}
Same without SDK:
async function infoPost<T>(body: object): Promise<T> {
const r = await fetch("https://api.hyperliquid.xyz/info", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`info ${r.status}`);
return r.json() as Promise<T>;
}
// infoPost({ type: "clearinghouseState", user, dex: "xyz" })
// infoPost({ type: "spotClearinghouseState", user })
9.2 Parsing clearinghouseState with Validation
type Pos = { coin: string; szi: number; positionValue: number; entryPx: number | null; unrealizedPnl: number; marginUsed: number; leverage: number | null };
type DexRead = { dex: string; ok: boolean; accountValue: number; marginUsed: number; ntl: number; positions: Pos[] };
const fin = (s: unknown) => (s === null || s === undefined || s === "" ? NaN : Number(s));
export function parseClearinghouse(ch: any, dex: string): DexRead {
const bad: DexRead = { dex, ok: false, accountValue: 0, marginUsed: 0, ntl: 0, positions: [] };
const ms = ch?.marginSummary;
if (!ms) return bad; // 200 without marginSummary — degradation, NOT $0
const av = fin(ms.accountValue);
const used = fin(ms.totalMarginUsed ?? "0");
const ntl = fin(ms.totalNtlPos ?? "0");
if (!Number.isFinite(av) || !Number.isFinite(used)) return bad;
if (used > 0 && av < used) return bad; // account value below margin would have been liquidated
if (!Array.isArray(ch.assetPositions)) return bad; // healthy response always carries an array
const positions: Pos[] = [];
const seen = new Set<string>();
for (const ap of ch.assetPositions) {
const p = ap?.position;
if (!p?.coin) continue;
const szi = fin(p.szi);
if (!Number.isFinite(szi)) return bad; // NaN — corrupted read, not flat
const pv = fin(p.positionValue);
if (szi === 0 || !Number.isFinite(pv) || pv === 0) continue;
if (seen.has(p.coin)) return bad;
seen.add(p.coin);
const lev = p.leverage ? Number(p.leverage.value) : null;
if (lev !== null && !(lev > 0)) return bad;
const upnl = fin(p.unrealizedPnl);
if (!Number.isFinite(upnl)) return bad;
positions.push({
coin: p.coin, szi, positionValue: Math.abs(pv),
entryPx: p.entryPx ? Number(p.entryPx) : null,
unrealizedPnl: upnl, marginUsed: Number(p.marginUsed ?? 0) || 0, leverage: lev,
});
}
// WS-variant of the same check: positions exist but margin is 0 — payload incomplete
if (positions.length > 0 && used === 0) return bad;
return { dex, ok: true, accountValue: av, marginUsed: used, ntl: Number.isFinite(ntl) ? ntl : 0, positions };
}
Duplicate coin checks are required between dexes. HIP-3 names have a prefix (xyz:), so intersection means a mistake.
9.3 Free Spot-Stables (Regular, Unified and PM Account)
const STRICT_NUM = /^-?\d+(\.\d+)?$/;
const strict = (s: unknown) => (typeof s === "string" && STRICT_NUM.test(s) ? Number(s) : NaN);
export function parseFreeStables(resp: unknown): { ok: boolean; free: number; total: number } {
const bad = { ok: false, free: 0, total: 0 };
if (!resp || typeof resp !== "object") return bad;
const r = resp as { balances?: any[]; portfolioMarginEnabled?: boolean };
if (!Array.isArray(r.balances)) return bad; // 200 without balances is not zero
const pm = r.portfolioMarginEnabled === true;
const seen = new Set<string>();
let free = 0, total = 0;
for (const b of r.balances) {
if (!STABLES.has(b?.coin)) continue; // spot-altcoins are not counted
if (seen.has(b.coin)) return bad;
seen.add(b.coin);
const t = strict(b.total);
const hold = strict(b.hold ?? "0");
const hasSpotHold = typeof b.spotHold === "string" && b.spotHold !== "";
const reserve = hasSpotHold ? strict(b.spotHold) : hold;
if (!Number.isFinite(t) || t < 0 || !Number.isFinite(hold) || !Number.isFinite(reserve) || reserve < 0) return bad;
if (hold < 0 && !(pm && hasSpotHold)) return bad; // negative hold is legal only on PM with spotHold
if (!pm && reserve > t) return bad; // on regular account, reserve > total — nonsense
free += Math.max(0, t - reserve); // on PM, reserve > total → deposit 0, no freeze
total += t;
}
return { ok: true, free, total };
}
// Test traps: USDT0 {total:"0.0", hold:"-999999.99999999", spotHold:"0.0"} on PM → free 0 (not $1M);
// USDC {total:"1000", hold:"200"} → free 800, total 1000; HYPE in any quantity → ignored.
9.4 Smoothing, Detector, Ledger
const WINDOW = 7;
const windows = new Map<string, number[]>();
export function median(xs: number[]): number {
const s = [...xs].sort((a, b) => a - b);
return s[Math.floor(s.length / 2)]; // upper median in case of even length
}
/** Smooth the SUM of one moment (perp from all dex + free spot). Untrusted reading does not feed the window. */
export function smoothEquity(key: string, value: number, trusted: boolean): number {
const w = windows.get(key) ?? [];
if (trusted) { w.push(value); if (w.length > WINDOW) w.shift(); windows.set(key, w); }
return w.length ? median(w) : 0;
}
/** Per dex: equity jumped, while exposure remains → do not trust the reading. */
export function isEquityImplausible(cur: { av: number; ntl: number }, prev: { av: number; ntl: number } | null): boolean {
if (!prev || prev.av <= 0 || prev.ntl <= 0) return false;
const exposureHeld = Math.abs(cur.ntl - prev.ntl) <= 0.10 * prev.ntl;
return exposureHeld && (cur.av < 0.75 * prev.av || cur.av > 1.33 * prev.av);
}
/** Actual withdrawal leaves a record in userNonFundingLedgerUpdates. */
export function ledgerExplainsDrop(
ledger: Array<{ time: number; delta?: { usdc?: string; amount?: string } }> | null,
sinceMs: number,
shortfall: number,
): boolean {
if (!ledger?.length || shortfall <= 0) return false;
let moved = 0;
for (const e of ledger) {
if (e.time < sinceMs) continue;
const v = Number(e.delta?.usdc ?? e.delta?.amount);
if (Number.isFinite(v)) moved += Math.abs(v);
}
return moved >= 0.5 * shortfall;
}
9.5 Preflight «account empty» before resetting keys/state
// With the same code and dex as the bot; two reads should match.
const a = await readAccount("0xYOUR_ADDRESS");
const b = await readAccount("0xYOUR_ADDRESS");
const sizes = (x: typeof a) => JSON.stringify(x.perDex.map((d) => d.positions.map((p) => [p.coin, p.szi])));
if (!a.positionsOk || !b.positionsOk || sizes(a) !== sizes(b))
throw new Error("reset refused: positions changed or read degraded");
const live = b.perDex.flatMap((d) => d.positions.filter((p) => p.szi !== 0));
// + open orders for each dex, INCLUDING trigger / TP-SL (frontendOpenOrders with dex)
if (live.length) throw new Error(`reset refused: account not flat, positions=${live.map((p) => `${p.coin}:${p.szi}`).join(",")}`);
At any read error, clearing is forbidden.
10. Pitfalls
- Only Main Dex.
clearinghouseStatewithoutdexhides xyz: equity is reduced by the entire xyz part, while the wallet, "flat" on the main dex, actually holds orders and positions on xyz. → Sum across all dexes fromperpDexs, verify order count with UI. - Hardcoded
['', 'xyz']. If a portion of capital lies on other builder-dexes, two-dex sum underestimates equity. → List dexes dynamically or explicitly log which dex the code does not see. crossMarginSummaryinstead ofmarginSummary. Equity for isolated positions is reduced by the margin of isolated positions. →marginSummary.- "Perps + Full Spot". On unified
hold, it mirrors perp-margin, formula gives ×2 and a false threshold alert. → free =total − reserve. total − max(0, hold)on PM account. Numerically matches Available Balance UI but includes borrow capacity. Denominator is ×2, each order is half the needed size. A test fixing the wrong number does not catch an error: check formula by identity on real accounts. →reserve = spotHold ?? hold, and a test-identity "reserves ≈ Σ perp" on real accounts.- String
USDT0 total 0.0 / hold -999999.99whenmax(0, total − hold)produces a phantom $1 000 000. → Account forspotHoldand PM flag. entryNtl ?? totalfor stablecoins. For USDC,entryNtl = "0", balance is lost entirely. → Calculate fromtotal/holdfor stablecoins.max(total, hold, entryNtl)on spot lines. Merges token units and USD. → For USD valuation, need prices; only stablecoins for capital.- HTTP 200 without
marginSummaryaccepted as $0. Below 429/5xx equity collapses to a part of one dex, and any calculation with equity in the denominator is inflated by orders of magnitude: market IoC goes beyond needed amount (or HL cancels it with "Insufficient margin"). Flagokdoes not trigger, no exception. → Absence of field = degradation; last-good cache 10 min; absolute ceiling size from trusted equity account; separate flagspositionsOk/ok/equityFresh(§6.2). accountValue === 0treated as an error. Wallets without xyz stop decreasing balance (cash is cached), wallets with withdrawn capital are masked by cash. →ok= "request successful"; full $0 clears cache.- Failed xyz fetch returns
accountValue = 0withok = false, and this 0 is added to total. Portfolio is underestimated, derivative ratios inflated. → Last-known xyz onok = false. - Per-dex denominator in margin ratio / per-dex ceiling. False blockages: orders are removed and not placed for hours. → Unified pool on unified.
- Spot read failure → 0 in equity. Calculation size from equity is underestimated, position is reduced by the market. → Last-known-good stablecoins, marked as not-fresh.
- Throw spot read inside
Promise.allsnapshot. The entire account is skipped on tick with guard and TP. → Degradate took: false. - One dex failure does not throw an exception. Positions falsely flat → double buy and removal of protective TPs. → Skip tick when
!positionsOk. szi= NaN read as flat. Bot closes a live position. → Non-finite number = corrupted read.- Protective trigger on single phantom. Positions are closed by the market with static exposure; "3 suspicious reads" counter burns out in ~30 seconds per burst. → Gate "equity collapsed, exposure stands", time episodes, ledger confirmation.
- Restart into phantom burst. Phantom becomes base, healthy readings rejected. → Trial 3 min, base = median.
- Phantom slips into the base on exposure change. Storm of rejections, balance unavailable. → Median window size 7.
- Gluing perp from one reading with spot of another / median by components. Phantom shift in the sum. → One-time reading, smoothing the sum.
- Comparison of peak recorded by another formula. False "drop" → latch and market position closure; false "capital growth" in the monitor. → Formula fingerprint next to the value.
- Peak raised by burst of exaggerated responses — permanently and silently overestimated. → Hysteresis with median window span ≥ 10 min.
- Guard watches only spot
totaland is blind to perp-to-spot reallocation on unified. False latch. → Two witnesses (total and free), maximum growth. - Perp-only metric as capital/profit/loss of the account. Spot ↔ perp overflows look like a deep loss. → Guard-metric with likelihood gating; denominator separately.
- Two modules — two equity formulas. Phantom divergence, equal to the spot share in capital. → One exported function.
- Cached equity authorizes position growth. → Cache only for holding protection, not for growth.
- Unreadable equity → exposure ceiling is disabled when
equity ≤ 0. → Ceiling from last-known-good. - "Repeat 3 times = fact" skips systematic error. Observed on unified account: shortly after the first order placements,
accountValuewas read above normal with static exposure and no fills; three identical repeats accepted this value as a fact, and the report showed an nonexistent "session PnL". Hypotheses: double accounting ofholdunder orders or sequential reading of perp and spot, i.e., from different moments/conventions. (medium) → Read both legs with onePromise.alland check equity for double reserve accounting upon order appearance. - WS-update overwrites REST-spot with zero: the spot-part of the balance "disappears" on each update. → Store last REST-spot.
- WS
spotState.totalRawUsdas spot-balance. Silently includes alts, distorts loss metrics. → OnlyspotClearinghouseState+ stable filter. - Display of "spot total + Σ uPnL". Double accounting: with a minus the balance is understated, divergence from trade.xyz noticeable. → Spot stables
totalwithout uPnL (only unified). - Display of "perp + xyz + free" underestimates by USDC, owed to isolated positions (discrepancy small). → For UI on unified take spot
total. For sizing leave perp + free. - Spot
totalas equity formula for sizing/risk-cap. Breaks in classic mode (spot empty) and swells with spot-limits. → Perp + free. - HL frontend "Total Equity" for sizing. More formulas §3.3 on collateral debt and spot-alts. → Formula §3.3.
- Manual curl to info "each time anew". Typical errors: missing xyz, confusion with front Total Equity, accounting of collateralized stables. → One script/function equity printing main/xyz/free stable splits and positions.
- ROE from entry, not mark. Stops and exits calculated from incorrect base. →
(mark − entry)/mark × lev≡uPnL/marginUsed, with identity test. - Exposure
|szi| × entryPx. Ceiling utilization works off outdated number. →positionValue. - Double entry due to clearinghouseState lag. Position doubles. → Local record for entry decision, mutex.
- Formula change without recalculation of saved bases (starting balance, peaks, baseline). Derivatives and triggers become garbage. → Forced rebaseline with the same deployment + formula fingerprint.
- Null in DB on HL failure overwrites last value and looks like "balance dropped". → Never write null.
- Baseline alert is not updated when alerts are disabled → phantom jump after enabling.
- Cache keys in different case addresses for the same address → cache misses and duplicates. →
toLowerCase().
11. Open Questions / Not Verified
- Info-requests weights. For
userNonFundingLedgerUpdatesandportfolio, there are weight estimates of 2 and 20 respectively. Plan with a higher value and verify against the official weight table and IP limit. A budget of "≈1000 weight/min" is a conservative throttler setting, not an official limit. - Per-dex vs unified pool isolation. Unified Account / DEX abstraction: the pool is fungible (fill 2026-08-28, quote from HIP-3 docs). There is also an opposite model where each HIP-3 dex has its own funds and requires a separate transfer. It is not verified whether this mode of the account is correct and how HIP-3 dexes with non-USDC collateral behave.
spotState.totalRawUsdinclearinghouseState. Code adds it to xyzaccountValue; debugging checks did not confirm its existence (low), but in WS snapshots, it seems to contain alts. Recommendation: do not add. The field form is unclear.webData2in WS supposedly carriesspotState, allowing spot reading without REST. This idea has not been verified.- Held USDC under isolated positions is not reflected in any dex
accountValue(observed on a small amount). This contradicts the identity "hold ≈ Σ perp accountValue", which otherwise holds to within a fraction of a percent. The mechanics are unexplained. - Borrows (
borrowed,ltv). The invariantcapital = spot.totalis stated "when there are no borrows". How to account for capital whenborrowed > 0is not clear. - Replica with
hold: "0.0"withoutspotHold(medium): frequency and conditions unknown. - Jump in
accountValueon unified account without fills after placing orders (medium): hypotheses (double accounting ofholdor different moments of reading perp/spot) not confirmed. - Lag in
clearinghouseStateafter order: ≤500 ms in some observations, 0.5–1 s in others. Assume ~1 s lag and re-read after 1.2 s. withdrawableon unified/PM accounts: semantics uninvestigated, not used in formulas of this base.- SDK types
@nktkas/hyperliquid0.27.x forspotHold,portfolioMarginEnabled,borrowed,ltv: not verified, fields read asunknownin snippets. accountValue = totalRawUsd + totalNtlPosis not from documentation. The sign semantics oftotalNtlPosfor shorts is not verified.- The exact composition of the HL frontend "Total Equity" is inferred from discrepancies (held collateral + spot alts) and is not officially verified.
- trade.xyz "Total Equity" = spot
totalis verified only on unified accounts with stable collateral. For classic, it is not verified.
Knowledge snapshot — 2026-09; dates of individual verifications are in the text. The HL API changes, so recheck limits and response forms.
© markpaper authors. Licensed under CC BY 4.0: when publishing or adapting, credit “markpaper — Hyperliquid knowledge base” and provide links to the original and the license.