Skip to content
markpaper

knowledge/hl/balance-and-equity.md

vregistry-c914171 · 64.9 KB

Download file
# Hyperliquid — Balance and Equity of the Account

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

1. **`clearinghouseState` provides data for one dex at a time.** Without a `dex` parameter, it returns only the main perp-dex. HIP-3 dex (`xyz` and other builder-dex) are queried separately with `dex: "xyz"`. Perp equity is calculated as Σ `marginSummary.accountValue` across **all** dexes; the list of dexes is obtained from `perpDexs`. 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.
2. **Use `marginSummary` instead of `crossMarginSummary`.** With isolated positions, `crossMarginSummary` shows a lower value and reports `totalNtlPos: 0`.
3. **Account's capital = Σ perp `accountValue` across all dexes + free spot-stables.** Free balance is calculated as `max(0, total − reserve)`, where `reserve = spotHold` if the field exists, otherwise `hold`. Stables include USDC, USDT, USDT0, USDH, and USDE. Spot-alt (HYPE, PURR, PIP…), staking, vaults, and HLP are not included: they reside outside of `clearinghouseState` and can be read separately (§1.7).
4. **On Unified Account, spot `hold` USDC 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 spot `total`.
5. **Portfolio margin** (`portfolioMarginEnabled: true`): `hold` goes negative; actual reserves are in `spotHold`. Expression `total − hold` is equivalent to Available Balance from UI but includes borrow capacity, hence the doubling. The line `total "0.0" / hold "-999999.99"` without considering `spotHold` turns into a phantom $1 000 000.
6. **Perp `accountValue` is 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.
7. **Unified Account collateral is fungible across dexes.** Per-dex `accountValue`/`withdrawable` does not show the capacity of this dex: `xyz.accountValue` can often be 0 even with live xyz positions. Margin ratio is calculated against the sum of dexes plus free spot.
8. **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 `marginSummary` can occur. What's needed: validation, detector for "equity jumped while totalNtlPos stayed the same," median window of ~7 readings, probation after restarts, and confirmation through `userNonFundingLedgerUpdates`.
9. **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.
10. **Spot data does not come via WS.** `allDexsClearinghouseState` does 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, `clearinghouseState` lags 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`

```jsonc
// {"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 a `Number()` conversion with a check using `Number.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.
- `marginSummary` covers cross + isolated margin, while `crossMarginSummary` only includes cross margin. On xyz with isolated positions, `marginSummary.accountValue` exceeds `crossMarginSummary.accountValue` by the margin of isolated positions, with cross having `totalNtlPos: 0`. For equity, use `marginSummary`, and keep `crossMarginSummary` as 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 ≥ totalMarginUsed`** in one response. Otherwise, the account would already be liquidated, meaning the response is corrupted.
- `spotState.totalRawUsd` is sometimes described as a field in the `clearinghouseState` response. Its existence and contents are not confirmed (see "Open Questions"). Do **not** use it as spot balance.

**Positions:**
- A position is open if `szi ≠ 0` **and** `positionValue ≠ 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. `entryPx` is 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), because `marginUsed = positionValue / leverage`, and `positionValue` is 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`

```jsonc
// {"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, `hold` is net “reserve − loan capacity”, and can be < 0.
- `total`/`hold` are expressed **in token units**, while `entryNtl` in USD. For stablecoins, `entryNtl = "0"`, not null, so the code `entryNtl ?? total` returns 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. `clearinghouseState` does 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 `allTime` curve starts from the account start (`"0.0"`).
- `perp*`-windows **aggregate perp-equity across all perp-dexes**. Confirmed: perp-total in `portfolio` = main `accountValue` + xyz `accountValue` to dollar. This allows for easy verification of per-dex sums.
- On Unified Account, the last value of `allTime.accountValueHistory` equals full capital = spot `total` = perp + free spot.
- **PnL should be taken from `pnlHistory`, not delta `accountValueHistory`**: deposits raise balance without profit. For week/month/allTime, use the last point in `pnlHistory`. If the response format is incorrect, return `null` (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 `pnlHistory` **starts with** `"0.0"`: this is PnL from the start of the window, not accumulated since account start;
  - The timestamps in `accountValueHistory` and `pnlHistory` for 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, `vlm` is a string;
  - An address without history gets not empty series but **11 evenly spaced `"0.0"` points** in each window and `vlm: "0.0"`, HL draws the grid. One such curve cannot distinguish an “empty account” from “no data”.
### 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

- `allDexsClearinghouseState` carries perp main + xyz (accountValue, marginUsed, positions), but **not spot**. Spot-stables are fetched via a REST request with a long cache.
- Inline `spotState.totalRawUsd` from the WS snapshot as a spot balance should **not be used**: it silently holds alt tokens.
- If storing `totalValue` only from the WS message, each update will erase the spot part of the reported balance. Correctly keep the last REST `spotValue` and 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 convey `totalMarginUsed`. 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 `accountValue`** as 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 `spotHold` USDC** (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:** `spotHold` bitwise equals `total`, no free spot, denominator = only perps. `total` remains as a reference "own funds".
- **Classic and Transfers:** `usdSend` from 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

```markdown
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 `accountValue` without 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: perp `accountValue` can be notably less than the real balance.
- The naive `accountValue ≤ 0 → ratio 0` allows entry at the point of liquidation: `0 >= cap` gives 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 `accountValue` of this dex, which was many times smaller than the order's notional. It was margined by free spot.
- **Per-dex utilization ceiling** (division by `available` for a specific dex) on Unified Account cannot be built: it falsely removes and blocks orders for hours when there is actually available margin.
- `accountValue` of a specific dex drops to zero with an active account when margin is used by another dex. Per-dex `withdrawable`/`accountValue` is 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 `accountValue` grows or falls due to `accountClassTransfer`.
- **Unified Account perp→spot transfer does not change the spot `total` by a cent.** Only the free/hold distribution changes. Verified on 2026-09-07: perps X → 0, spot `total` did 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 only `hold`.
- **Drop in perp-equity ≠ loss and ≠ withdrawal.** Verify sources:
  - Loss — `userFillsByTime` (`closedPnl`) **and** `userTwapSliceFills` (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.
- Visible drop in perp-equity may not be confirmed by fills (`closedPnl` over 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 `total` blind to the mirror release on unified (perps→spot), spot `free` blind 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 only `total` gives a false latch.
- Before believing "withdrawal" latch: add perps + free spot at peak and now. Matched — means rollover. Check consistency of saved triplet: `spotTotalAtPeak − spotFreeAtPeak` must match `perpAtPeak`.
- **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):
- `marginSummary` exists and `accountValue` is 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`. For `szi ≠ 0`, `leverage.value > 0`.
- No duplicates of `coin` between 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:
- `ok` means 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 with `ok = 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 check `positionsOk`. 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)`, where `totalNtlPos` has 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 inside `Promise.all` of 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 `clearinghouseState` failure of one dex may **not throw**, but resolve to `positionsOk = 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 `reduceOnly` shows a closed position → false "failed to close". Closure indicator — local record, deleted on fill. If re-reading HL, then after `sleep(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 tolerance `10^-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 between `pnl` (exchange; null if none) and `change` (snapshots) and always return `since`, 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 `deltaUsd` ledger flows since the start of the session, deduped by `hash`. 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 — `entryPx` position × actual fill (without REST and without race). Fallback — `unrealizedPnl` position, 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)

```ts
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:

```ts
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

```ts
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)

```ts
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

```ts
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

```ts
// 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

1. **Only Main Dex.** `clearinghouseState` without `dex` hides 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 from `perpDexs`, verify order count with UI.
2. **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.
3. **`crossMarginSummary` instead of `marginSummary`.** Equity for isolated positions is reduced by the margin of isolated positions. → `marginSummary`.
4. **"Perps + Full Spot".** On unified `hold`, it mirrors perp-margin, formula gives ×2 and a false threshold alert. → free = `total − reserve`.
5. **`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.
6. **String `USDT0 total 0.0 / hold -999999.99`** when `max(0, total − hold)` produces a phantom $1 000 000. → Account for `spotHold` and PM flag.
7. **`entryNtl ?? total` for stablecoins.** For USDC, `entryNtl = "0"`, balance is lost entirely. → Calculate from `total`/`hold` for stablecoins.
8. **`max(total, hold, entryNtl)` on spot lines.** Merges token units and USD. → For USD valuation, need prices; only stablecoins for capital.
9. **HTTP 200 without `marginSummary` accepted 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"). Flag `ok` does not trigger, no exception. → Absence of field = degradation; last-good cache 10 min; absolute ceiling size from trusted equity account; separate flags `positionsOk`/`ok`/`equityFresh` (§6.2).
10. **`accountValue === 0` treated 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.
11. **Failed xyz fetch returns `accountValue = 0` with `ok = false`,** and this 0 is added to total. Portfolio is underestimated, derivative ratios inflated. → Last-known xyz on `ok = false`.
12. **Per-dex denominator in margin ratio / per-dex ceiling.** False blockages: orders are removed and not placed for hours. → Unified pool on unified.
13. **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.
14. **Throw spot read inside `Promise.all` snapshot.** The entire account is skipped on tick with guard and TP. → Degradate to `ok: false`.
15. **One dex failure does not throw an exception.** Positions falsely flat → double buy and removal of protective TPs. → Skip tick when `!positionsOk`.
16. **`szi` = NaN read as flat.** Bot closes a live position. → Non-finite number = corrupted read.
17. **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.
18. **Restart into phantom burst.** Phantom becomes base, healthy readings rejected. → Trial 3 min, base = median.
19. **Phantom slips into the base on exposure change.** Storm of rejections, balance unavailable. → Median window size 7.
20. **Gluing perp from one reading with spot of another / median by components.** Phantom shift in the sum. → One-time reading, smoothing the sum.
21. **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.
22. **Peak raised by burst of exaggerated responses** — permanently and silently overestimated. → Hysteresis with median window span ≥ 10 min.
23. **Guard watches only spot `total`** and is blind to perp-to-spot reallocation on unified. False latch. → Two witnesses (total and free), maximum growth.
24. **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.
25. **Two modules — two equity formulas.** Phantom divergence, equal to the spot share in capital. → One exported function.
26. **Cached equity authorizes position growth.** → Cache only for holding protection, not for growth.
27. **Unreadable equity → exposure ceiling is disabled when `equity ≤ 0`.** → Ceiling from last-known-good.
28. **"Repeat 3 times = fact" skips systematic error.** Observed on unified account: shortly after the first order placements, `accountValue` was 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 of `hold` under orders or sequential reading of perp and spot, i.e., from different moments/conventions. (medium) → Read both legs with one `Promise.all` and check equity for double reserve accounting upon order appearance.
29. **WS-update overwrites REST-spot with zero:** the spot-part of the balance "disappears" on each update. → Store last REST-spot.
30. **WS `spotState.totalRawUsd` as spot-balance.** Silently includes alts, distorts loss metrics. → Only `spotClearinghouseState` + stable filter.
31. **Display of "spot total + Σ uPnL".** Double accounting: with a minus the balance is understated, divergence from trade.xyz noticeable. → Spot stables `total` without uPnL (only unified).
32. **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.
33. **Spot `total` as equity formula for sizing/risk-cap.** Breaks in classic mode (spot empty) and swells with spot-limits. → Perp + free.
34. **HL frontend "Total Equity" for sizing.** More formulas §3.3 on collateral debt and spot-alts. → Formula §3.3.
35. **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.
36. **ROE from entry, not mark.** Stops and exits calculated from incorrect base. → `(mark − entry)/mark × lev` ≡ `uPnL/marginUsed`, with identity test.
37. **Exposure `|szi| × entryPx`.** Ceiling utilization works off outdated number. → `positionValue`.
38. **Double entry due to clearinghouseState lag.** Position doubles. → Local record for entry decision, mutex.
39. **Formula change without recalculation of saved bases** (starting balance, peaks, baseline). Derivatives and triggers become garbage. → Forced rebaseline with the same deployment + formula fingerprint.
40. **Null in DB on HL failure** overwrites last value and looks like "balance dropped". → Never write null.
41. **Baseline alert is not updated when alerts are disabled** → phantom jump after enabling.
42. **Cache keys in different case addresses for the same address** → cache misses and duplicates. → `toLowerCase()`.
---

## 11. Open Questions / Not Verified

- **Info-requests weights.** For `userNonFundingLedgerUpdates` and `portfolio`, 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.totalRawUsd` in `clearinghouseState`.** Code adds it to xyz `accountValue`; 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.
- **`webData2` in WS** supposedly carries `spotState`, 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 invariant `capital = spot.total` is stated "when there are no borrows". How to account for capital when `borrowed > 0` is not clear.
- **Replica with `hold: "0.0"` without `spotHold`** (medium): frequency and conditions unknown.
- **Jump in `accountValue` on unified account without fills after placing orders** (medium): hypotheses (double accounting of `hold` or different moments of reading perp/spot) not confirmed.
- **Lag in `clearinghouseState` after order:** ≤500 ms in some observations, 0.5–1 s in others. Assume ~1 s lag and re-read after 1.2 s.
- **`withdrawable`** on unified/PM accounts: semantics uninvestigated, not used in formulas of this base.
- **SDK types `@nktkas/hyperliquid` 0.27.x** for `spotHold`, `portfolioMarginEnabled`, `borrowed`, `ltv`: not verified, fields read as `unknown` in snippets.
- **`accountValue = totalRawUsd + totalNtlPos`** is not from documentation. The sign semantics of `totalNtlPos` for 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 `total`** is 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.

---

<!-- license-footer -->
_© markpaper authors. Licensed under [CC BY 4.0](LICENSE.md): when publishing or adapting, credit “markpaper — Hyperliquid knowledge base” and provide links to the original and the license._
All files