# Hyperliquid — Market Data

A guide to HL market-data info requests (`POST https://api.hyperliquid.xyz/info`): market metadata, price and size precision, mid prices, order books, candles, HIP-3 dexes (`xyz:` and others), spot (`@index`), tokenized-equity trading hours, and delisted/isolated-only markets. Identifiers are preserved exactly as they appear in the API.

---

## TL;DR

1. **Every HIP-3 dex is a separate universe.** Without a `dex` field, `meta`, `metaAndAssetCtxs`, `allMids`, `clearinghouseState`, and `frontendOpenOrders` return **only the main perp dex**. `xyz` markets appear only in requests with `dex: 'xyz'`. Forgetting `dex` makes an account that trades only on xyz look “empty.” The exception is `userFills`/`userFillsByTime`: the `dex` parameter is ignored (verified 2026-06-11: responses with and without `dex:'xyz'` were identical and their `tid` sets matched). One request without `dex` already includes `xyz:*` fills; a second request doubles weight and duplicates fills when merged. Identify xyz by the `coin` prefix.
2. **Asset id:** main perp = index in `meta.universe`; spot = `10000 + index`; HIP-3 = `100000 + perpDexIndex × 10000 + index in that dex's universe`. For `xyz`, `perpDexIndex = 1` currently, so `xyz:TSLA = 110001` and `xyz:SP500 = 110052`. Resolve `perpDexIndex` **by name** from `{type:'perpDexs'}`; do not hard-code it.
3. **HIP-3 market names are prefixed:** `xyz:TSLA`. HL changed the format: `meta` with `dex:'xyz'` now returns `universe[].name` **already prefixed**, whereas it used to return a bare name. Normalize idempotently or you will produce `xyz:xyz:TSLA`, silently preventing every xyz order from being placed.
4. **Spot in the `coin` field:** `PURR/USDC` or `@<index>` (`@107`, `@142`). Filter with `coin.includes('/') || coin.startsWith('@')`.
5. **Precision.** Lot size = `10^-szDecimals` (`szDecimals` from meta, 0..8). Perp prices obey two limits: no more than 5 significant figures and no more than `6 − szDecimals` decimal places. When price crosses a power of 10, the tick changes by 10x. Minimum order notional is $10.
6. **`candleSnapshot`:** no more than about 5000 candles per call. If the window is wider, the **most recent** candles are returned. For 5m/15m, only the latest ~5000 bars are retained (5m ≈ 18 days, 15m ≈ 52 days). The final bar is open. OHLCV values arrive as strings. The minimum interval is 1m; there are no second candles.
7. **HIP-3 candles:** use `{coin:'xyz:SP500'}` without `dex`. `{coin:'SP500', dex:'xyz'}` returns **HTTP 500** (verified 2026-07-29).
8. **`meta` is global** and identical for every wallet. Keep one process-wide cache (5-minute TTL) with single-flight, stale-on-error, and a 30-second failure cooldown. Without single-flight, TTL expiry causes a stampede of heavy requests, and metadata can consume most of the total weight (§14).
9. **Tokenized equities on `xyz`:** outside the US session, the oracle freezes. The perp remains tradable, but fills occur at stale prices in a thin book. Open new positions only 04:00–20:00 ET, Monday–Friday, excluding NYSE holidays. `xyz:CL` (oil) trades 24/7.
10. **Info-request weights:** `allMids`, `l2Book`, `clearinghouseState`, and `spotClearinghouseState` cost 2. `meta`, `metaAndAssetCtxs`, `frontendOpenOrders`, and `candleSnapshot` cost 20. Fetch the `allMids` map **once per tick per dex**, not once per coin.

---

## 1. Market-Data Info Request Map

| `type` | Parameters | Response (brief) | Weight | Per-dex |
|---|---|---|---|---|
| `meta` | `dex?` | `{ universe: [{ name, szDecimals, maxLeverage, onlyIsolated?, isDelisted? }] }` | 20 | yes |
| `metaAndAssetCtxs` | `dex?` | tuple `[metaObject, assetCtxs[]]`, `assetCtxs.length === universe.length` | 20 | yes |
| `perpDexs` | — | `[null, { name, assetToStreamingOiCap: [[asset, cap]], … }, …]` | not measured | — |
| `allMids` | `dex?` | `Record<coin, midString>` | 2 | yes |
| `l2Book` | `coin` (full name, `xyz:TSLA`) | `{ coin, time, levels: [bids[], asks[]] }` | 2 | by `coin` prefix |
| `candleSnapshot` | `req: { coin, interval, startTime, endTime }` | `[{ t, T, s, i, o, c, h, l, v, n }]` | 20 | by `coin` prefix; **do not pass `dex`** |
| `recentTrades` | `coin` | latest 10 trades, with both counterparties' addresses (`users`) | not measured | — |
| `clearinghouseState` | `user`, `dex?` | positions and margin for one dex | 2 | yes |
| `spotClearinghouseState` | `user` | `balances` | 2 | no |
| `frontendOpenOrders` | `user`, `dex?` | open orders for one dex | 20 | yes |
| `userFills` / `userFillsByTime` | `user` | fills for all dexes at once; xyz `coin` values are prefixed with `xyz:` | — | no (`dex` is ignored) |

**The `dex` field.** For the main perp dex, omit the `dex` key **entirely**: pass neither an empty string nor `null`. It is convenient to represent the main dex as `''` in internal configuration, but that key must not enter the request body:

```ts
const withDex = <T extends object>(body: T, dex: string) => (dex ? { ...body, dex } : body);
// withDex({ type: 'allMids' }, '')    -> { type: 'allMids' }
// withDex({ type: 'allMids' }, 'xyz') -> { type: 'allMids', dex: 'xyz' }
```

Response codes: unknown `dex` for `clearinghouseState`, `openOrders`, `frontendOpenOrders`, or `meta` → **500 with an empty body** (verified live 2026-09-16; the earlier “422” note was wrong—422 on HL means request-body deserialization failed); `allMids` with an unknown `dex` → 200 `null`; rate-limit excess → **429**; `candleSnapshot` with a bare HIP-3 name plus `dex` → **500**.

### Calls Through SDK `@nktkas/hyperliquid` (0.27.x)

```ts
import * as hl from '@nktkas/hyperliquid';

// Check the client constructor against your SDK minor version.
const info = new hl.InfoClient({ transport: new hl.HttpTransport() });

const mainMids = await info.allMids();                    // Record<string, string>; NO xyz pairs here
const xyzMids  = await info.allMids({ dex: 'xyz' });      // keys such as 'xyz:TSLA'
const mainMeta = await info.meta();
const xyzMeta  = await info.meta({ dex: 'xyz' });         // universe[].name is already 'xyz:TSLA'
const dexs     = await info.perpDexs();                   // [null, { name: 'xyz', ... }, ...]
const book     = await info.l2Book({ coin: 'xyz:TSLA' }); // HIP-3: full prefixed name
```

The SDK response format matches raw `POST /info`: `meta` through raw `fetch` and through the SDK returns identical `name/szDecimals/maxLeverage/onlyIsolated` values.

---

## 2. `meta` and `metaAndAssetCtxs`

### Shape

- `{type:'meta'}` → `{ universe: [{ name: string, szDecimals: number, maxLeverage: number, onlyIsolated?: boolean, isDelisted?: boolean }] }`.
- `{type:'metaAndAssetCtxs'}` → `[metaObject, assetCtxs[]]`. `universe` and `assetCtxs` have equal lengths.
- For a HIP-3 dex, add `dex: 'xyz'` to both requests.
- A main-dex coin's **asset index** (field `a` in an order, `asset` in `updateLeverage`, and `a` in `cancel`) is the element's position in `universe`.
- The main meta response (without `dex`) contains **no** HIP-3 coins.
- `name`: an unprefixed ticker on main (`HYPE`, `BTC`). On HIP-3 it is now prefixed (`xyz:MU`), but it used to be bare (see “Pitfalls”).
- If a position or WS message lacks `coin` but has a numeric `asset`, resolve the name as `universe[asset].name` from metadata for **the same dex**. While `universe` is empty (initial metadata load failed and there is no cache), the name cannot be resolved, so discard that position.
- When parsing: `maxLeverage = Number(u.maxLeverage) || 1`, `szDecimals = Number(u.szDecimals)`.

### Reasonable Response Validation

These are client-side safety bounds, not an HL contract:

- `szDecimals` is a safe integer in `0..8` (“HL perp sizes currently use 0..8 decimals”);
- `maxLeverage` is `1..1000`;
- `onlyIsolated`, if present, is boolean;
- `name` is at most 128 characters and contains no control characters; duplicate names are errors;
- `universe` length: at most 100,000 on main and 10,000 on a builder dex;
- **one malformed element invalidates the entire universe.** It cannot be skipped because doing so shifts the index of every following element.

### Why Asset IDs Need Protection

An order sends a **number** (the position in `universe`), not a coin name. Consider valid JSON missing one element (a truncated 200 response): every subsequent number shifts by one, so an MU order is sent to a different instrument. After a cold start, the process has no previous map for comparison. Use three layers of protection:

1. **Double read.** Request `meta` and `metaAndAssetCtxs` in parallel and compare `name`, `szDecimals`, `maxLeverage`, and `onlyIsolated` element by element. Also require `universe.length === assetCtxs.length`. Any mismatch invalidates the entire universe. Cost: 40 weight.
2. **Append-only updates.** In a new map, every previously verified asset must keep the same `assetIndex` and `szDecimals`. If an asset disappears or changes index or `szDecimals`, reject the update and retain the last verified map. For execution, existing assets may only be appended to, never changed.
3. **HIP-3 universe completeness against the `perpDexs` registry.** The `universe` returned by `meta` for `xyz` must **contain** every asset from that dex's `assetToStreamingOiCap`. Require inclusion, not equality (see “Pitfalls,” the XBI listing).

### Caching Meta

Metadata changes only on a listing or delisting, meaning once every hours or days. Therefore:

- **one process-wide cache**, shared by all subsystems (monitoring and execution). Separate caches for the same metadata double the heavy requests;
- **5-minute TTL.** It can be longer, but 5 minutes guarantees a new listing reaches the cache within 5 minutes. A bot with a fixed market set can load metadata once at startup: zero meta requests per tick;
- **single-flight.** Concurrent callers await one in-flight Promise;
- **stale-on-error.** Return a stale cache on failure because asset index and `szDecimals` almost never change. If there is no cache, propagate the error. A failed metadata load without a cache blocks **every** order, including protective and closing orders, so stale metadata is better than none;
- **30-second failure cooldown.** After a failure, do not hammer the endpoint; serve stale data for 30 seconds. Store `lastFailAt` **separately** from cache `ts` so a recovered endpoint is picked up immediately rather than after 5 minutes;
- with the double read (item 1 above), retries without cooldown are especially expensive: every call rebuilds the map for 40 weight. One bad tick can consume the request queue and delay closes.

```ts
let cache: { ts: number; universe: UniverseItem[] } | null = null;
let inflight: Promise<UniverseItem[]> | null = null;
let lastFailAt = 0;
const TTL = 5 * 60_000;
const FAIL_COOLDOWN = 30_000;

async function getMainUniverse(): Promise<UniverseItem[]> {
  if (cache && Date.now() - cache.ts < TTL) return cache.universe;
  if (cache && Date.now() - lastFailAt < FAIL_COOLDOWN) return cache.universe; // do not hammer a failing endpoint
  if (inflight) return inflight;                                               // single-flight
  inflight = (async () => {
    try {
      const u = await loadUniverse(/* dex */ null);
      cache = { ts: Date.now(), universe: u };
      return u;
    } catch (err) {
      if (cache) { lastFailAt = Date.now(); return cache.universe; }           // stale is better than empty
      throw err;
    } finally {
      inflight = null;
    }
  })();
  return inflight;
}
```

The same pattern works for other process-wide caches (for example, candles).

**Memoizing the derived map.** Build the `Map<coin, AssetMeta>` index once per `universe` array by comparing references (`memo.universeRef === universe`), not once per order.

### Isolating Main and HIP-3 Failures

- HIP-3 metadata is **optional relative to main**: an xyz failure must not break main metadata. Otherwise protective closes for main positions fail after a cold start.
- You also cannot cache only the main map after an xyz transport failure: once the TTL expires, every xyz coin becomes “no metadata,” so xyz positions cannot be canceled or closed.
- Correct design:
  - transport error or malformed response → carry forward **previously verified** xyz entries from the old cache;
  - **topology changed** (`perpDexs` does not contain exactly one `xyz`, or its index changed) → **discard** xyz asset ids because they may point to another instrument. Raise an alert.
- Reuse the xyz offset only if **this process** verified it through `perpDexs`. If `perpDexs` is unavailable and there is no verified value, close xyz to trading (fail closed).

### “Absent from the Map” ≠ “Delisted”

- A coin may be absent from the asset map because the map is stale or degraded: HL sometimes returns stale metadata, and xyz ids may have been disabled fail-closed.
- Listing status has three values: `listed | unlisted | unknown`. A metadata error yields `unknown`, not `unlisted`, and must not drive decisions.
- Memoize the **attempt**, not just success. Otherwise every check during a metadata outage starts a full universe rebuild.

---

## 3. Precision: `szDecimals`, Lot, and Tick

| Quantity | Formula / rule |
|---|---|
| Size step (lot) | `10^-szDecimals` |
| Maximum price decimals (perp) | `pxDecimals = max(0, 6 − szDecimals)` |
| Price significant figures | no more than 5 |
| Effective tick | `max(10^-pxDecimals, 10^(floor(log10(px)) − 4))` |
| Minimum order notional | $10: a smaller order is rejected (`REJECTED`) |

Examples from live mainnet:

| Market | Data | Observation |
|---|---|---|
| `HYPE` | asset `159`, `szDecimals 2`, `pxDecimals 4`, `maxLeverage 10x` | lot 0.01; between prices 10 and 100, price has at most 3 decimal places (illustrative `81.234`): the 5-significant-figure limit binds before the 4-decimal limit; around 83–86 the tick is $0.001 |
| `SOL` | ~101–104 | at ≥100 the tick is 0.01; below 100 the tick is 0.001 |
| `BTC` | ~76,500 | $1 tick |
| `xyz:MU` | ~1011 | price `1000.2` has a 0.1 tick (≥1000), while `950.02` has a 0.01 tick (below 1000) |
| `xyz:STRC` | `szDecimals 1`, price ~$87 | lot 0.1 = $8.68, so **one lot is below the $10 minimum** |

```ts
const pxDecimals = (szDecimals: number) => Math.max(0, 6 - szDecimals);

// Tick at px: changes by 10x when crossing a power of 10
function tickAt(px: number, szDecimals: number): number {
  const bySigFigs = 10 ** (Math.floor(Math.log10(px)) - 4);
  return Math.max(bySigFigs, 10 ** -pxDecimals(szDecimals));
}

function roundPx(px: number, szDecimals: number): number {
  const sig = Number(px.toPrecision(5));
  return Number(sig.toFixed(pxDecimals(szDecimals)));
}

// Epsilon is required: 0.29 * 100 = 28.999999999999996. Wherever size is rounded,
// call THE SAME function (see orders.md §5.3).
function floorSz(sz: number, szDecimals: number): number {
  const f = 10 ** szDecimals;
  return Math.floor(sz * f + 1e-9) / f;
}
```

Rules:

- “move by N ticks” logic must account for the tick jump at powers of 10;
- **after every size recalculation** (book-depth reduction or proportional reduction due to leverage), floor again to `szDecimals` and recheck the $10 minimum, or the reduced size will return `REJECTED`.

**HL candles lie on the same grid [verified 2026-09-23].** A bit-for-bit check covered 10.5 million bars recorded from WS `candle` and REST `candleSnapshot` (1m: 5.1 million; 5m…1d: 5.4 million; 325 main-dex and HIP-3 markets). For 100% of bars, `o`, `h`, `l`, and `c` were exact decimals with no more than `max(0, 6 − szDecimals)` decimal places, and volume `v` was an exact decimal with no more than `szDecimals` places (float8 parsed from the response string equaled `round(x·10^d) / 10^d` bit for bit). Thus HL candles can be stored losslessly as integer ticks. Volume sums calculated locally with floating-point addition (candles built from trades or minutes rolled into hours) usually **do not** lie on the grid (`0.1 + 0.2 = 0.30000000000000004`); that is a property of your code, not HL data. Across 2026-09-15…09-23, `szDecimals` did not change for any of 532 markets in saved metadata snapshots; whether it changed earlier is not verified.

---

## 4. HIP-3 (Builder-Deployed) Perp Dexes

### What They Are

- A HIP-3 dex is a **separate exchange inside HL**: its own universe, prices (`allMids`), margin context (`clearinghouseState`), and funds. Read everything separately for each dex.
- Market names have the form `<dex>:<COIN>`, for example `xyz:SPCX`. This is **genuinely a different market**, not the same as `SPCX` on main: on HL, the coin name is the market identity.
- A market's dex is the portion before `:`; no colon means the main perp dex (`''`). The prefix determines the asset class: prefixed markets are HIP-3 (equities, indices, commodities), while unprefixed markets are crypto on main.
- The dex read list **always** includes main `''` plus every required HIP-3 dex, even if you trade only equities.
- With DEX abstraction enabled, collateral is shared: USDC on main backs xyz orders. An agent trading on main and xyz needs the `agentEnableDexAbstraction` action. The agent signs it, it is called lazily before the first xyz order, and it is idempotent. See the accounts and balances documentation for details.

### `perpDexs`

```jsonc
// POST /info {"type":"perpDexs"}
[
  null,                                     // index 0 is always the main perp dex
  { "name": "xyz", "assetToStreamingOiCap": [["xyz:TSLA", "…"], …], … },
  { "name": "flx", … },
  …
]
```

- The element's array position is the `perpDexIndex` used in the asset-id formula.
- The array has length ≥2 and element 0 is `null`. Internal `null` gaps can also occur; they are part of the schema and preserve indices. Test with `d && typeof d.name === 'string'` and build `Map<name, index>` from matching entries.
- `cap` in `assetToStreamingOiCap` arrives as a string or number.
- An empty `assetToStreamingOiCap` is valid for a newly created or not-yet-live dex. For a dex you trade on, the registry must be nonempty because it confirms metadata-universe completeness after a cold start.
- Safety invariant: `name === 'xyz'` occurs exactly once at an index > 0; otherwise the topology is unsafe (fail closed).
- If `perpDexs` cannot be read, do not build HIP-3 metadata. If the required dex is absent from `perpDexs`, do not build it either.

**The dex list grows—enumerate it with a request instead of relying on memory:**

| Date | `perpDexs` (in order) |
|---|---|
| 2026-05-25 | `null, xyz, flx, vntl, hyna, km, abcd, cash, para` |
| 2026-06-23 | `null, xyz, flx, vntl, hyna, km, abcd, cash, para, mkts` |
| 2026-09-07 | same plus `io`: 11 elements including main |

A hard-coded dex list silently becomes stale: it will not see positions or orders on a new dex (such as `io` in 2026-09).

**Coverage.** Polling each additional dex adds heavy requests (`frontendOpenOrders` = 20) for every account. A complete account read must iterate over every dex in `perpDexs`.

### Example `xyz` Markets (2026-09)

- Equities, for example: `TSLA, HOOD, AMD, MU, SNDK, SKHX, SPCX, STRC, XBI`; get the full list from `meta` with `dex:'xyz'`.
- Indices: `SP500, XYZ100`.
- Commodities and others: `CL` (oil), `GOLD`, `BTC`, `EUR`.
- Asset-id smoke values: `xyz:TSLA = 110001`, `xyz:MU = 110015`, `xyz:SNDK = 110016`, `xyz:CL = 110029`, `xyz:SP500 = 110052`. Indices did not shift when HL changed the name format.

### Trading Particulars on `xyz`

- **Liquidity is substantially lower than on main.** A reduce-only IoC with a narrow limit around mid may fail to cross the book and be rejected. Reduce-only exits on thin markets need a wide limit around mid.
- **`maxLeverage` varies widely:** `xyz:HOOD = 10x`, `xyz:SP500 = 50x`. Leverage above the maximum makes `updateLeverage` fail. Leverage is an integer ≥1: `lev = Math.min(Math.max(1, Math.floor(want)), meta.maxLeverage)`; reduce notional proportionally.
- **`onlyIsolated` is `true` for the vast majority of xyz pairs.** Observed xyz positions were isolated only (`accountValue == totalMarginUsed`, cross 0). For xyz, if the field is absent, it is safer to treat the market as isolated-only: `onlyIsolated = u.onlyIsolated !== false`. For main use `onlyIsolated = u.onlyIsolated === true`. Therefore set `isCross = !onlyIsolated` in `updateLeverage`.

---

## 5. Asset ID Summary

| Market | Asset id | Example |
|---|---|---|
| Main perp | index in `meta.universe` | `BTC`/`ETH` → small index (<100, usually 0–5), `HYPE` → `159` |
| Spot | `10000 + pair index` | pair `@85` → `10085` |
| HIP-3 perp | `100000 + perpDexIndex × 10000 + index in meta({dex}).universe` | `xyz` (index 1): `xyz:TSLA` → `110001`, `xyz:SP500` → `110052` |

Each builder dex receives an id block exactly 10,000 wide. An id error sends the order **to another market**.

```ts
const hip3AssetId = (dexIndex: number, indexInUniverse: number) =>
  100_000 + dexIndex * 10_000 + indexInUniverse;

async function buildXyzAssetMap(info: hl.InfoClient): Promise<Map<string, AssetMeta>> {
  const dexs = await info.perpDexs();
  const matches = dexs
    .map((d, i) => (d && typeof d.name === 'string' && d.name === 'xyz' ? i : -1))
    .filter((i) => i > 0);
  if (matches.length !== 1) throw new Error('unsafe xyz topology'); // fail closed; do not trade xyz
  const dexIndex = matches[0];

  const { universe } = await info.meta({ dex: 'xyz' });
  const map = new Map<string, AssetMeta>();
  universe.forEach((u, i) => {
    if (!u?.name) return;
    const coin = u.name.startsWith('xyz:') ? u.name : `xyz:${u.name}`; // robust to both formats
    map.set(coin, {
      assetId: hip3AssetId(dexIndex, i),
      szDecimals: Number(u.szDecimals),
      maxLeverage: Number(u.maxLeverage) || 1,
      onlyIsolated: u.onlyIsolated !== false,
    });
  });
  return map;
}
```

After changing how the universe is obtained, run a smoke test: known pairs must produce the same ids (`xyz:TSLA = 110001`, and so on).

---

## 6. `allMids`

- `{type:'allMids'}` → `Record<coin, string>`: mid prices for **all** coins on the **main** dex in one response, with prices as strings.
- A response without `dex` contains **neither** `SKHX` **nor** `xyz:SKHX`. HIP-3 requires `{type:'allMids', dex:'xyz'}`, where the key is `xyz:SKHX`. Bare keys have also been observed, so try both forms: `mids[coin] ?? mids[bare]`.
- Maps from several dexes can be merged into one `Map` keyed by the **full** market name.
- **Contents of the response without `dex` (live read-only request, 2026-09-22):** 1102 keys—main-dex perps, **spot** (409 `@N` keys plus `PURR/USDC`), and **outcome markets** prefixed with `#` (`#12090`, …). All 1102 values were decimal strings with a fractional part (none like `"100000"` without `.0`). To keep perps only, filter out keys containing `@`, `/`, or `#`.
- **Unknown `dex`** (`{type:'allMids', dex:'nosuchdex'}`) → HTTP 200 with body `null` (2026-09-16 and 2026-09-22), not `{}` or an error. The “response is not an object → failure” validation below catches this.

**Validation:**

- response is not an object, is an array, or is empty (`Object.keys(x).length === 0`) → treat the request as failed and use `{}` for that dex's map;
- mid is non-finite, ≤0, or above `Number.MAX_SAFE_INTEGER` → mid is **unavailable** (`null`). In a “cancel protective order, then place close” flow, an unchecked `Infinity` could leave the position unprotected if the replacement is rejected;
- no mid for the coin → skip the coin for this tick and SKIP the order.

**Load:**

- fetch the map **once per tick per dex** and look up many coins in it instead of requesting once per strategy or coin; this greatly reduces load. Likewise, read account state (orders + positions + equity) once per account per tick and reuse it;
- use a **2-second** cache plus single-flight, not 5 seconds: the IoC limit price is derived from mid, and a fresher mid keeps the entry closer to the decision-time price in a fast market. At weight 2, the extra cost is negligible;
- without single-flight, N concurrent consumers on a cold cache produce N concurrent `allMids` requests (2N weight);
- price-tick cost scales with the number of **coins**, not positions: many positions across a few coins still require one REST request per dex;
- frequent `allMids` calls (every tick) are normal at weight 2 when cached and deduplicated.

---

## 7. `l2Book`

```jsonc
// POST /info {"type":"l2Book","coin":"xyz:TSLA"}
{
  "coin": "xyz:TSLA",
  "time": 1757000000000,
  "levels": [
    [ { "px": "250.10", "sz": "12.5", "n": 3 }, … ],  // levels[0] = bids, descending by price
    [ { "px": "250.20", "sz": "4.0",  "n": 1 }, … ]   // levels[1] = asks, ascending by price
  ]
}
```

- `px` and `sz` arrive as strings; `n` is the number of orders at the level. `levels[0][0]` is best bid and `levels[1][0]` is best ask. `mid = (bestBid + bestAsk) / 2`.
- For HIP-3, pass `coin` **in full with its prefix**. HL's asset-id documentation says “the system expects the full {dex}:{coin} formatted name.” Main and HIP-3 response shapes are identical.
- A WS `l2Book` frame has the same shape (`data.coin`, `data.levels`, `data.time`).

**Parsing:**

- `null`, missing `levels`, non-array `levels`, or fewer than 2 elements (for example `{levels:[[]]}`) → `null`. The caller must fail open;
- an empty book `{levels:[[],[]]}` is valid: `{bids:[], asks:[]}`. It is still unsuitable for trading decisions (“empty book”);
- discard levels with a nonnumeric `px` (for example `'abc'`);
- `!(bestBid > 0 && bestAsk > bestBid)` means a malformed book; do not trade from it.

**Cache:** per coin, 2-second TTL (the book should live no longer than mid), plus single-flight. **Cache errors for the TTL too**, so N consecutive calls during a 429 storm do not hammer an unavailable endpoint.

**Unknown-input responses and aggregation (live read-only requests, 2026-09-22):**

| Request | HL response |
|---|---|
| unknown `coin` (`NOSUCHCOIN`) or `coin: ""` | 200, body `null`—not an empty book |
| missing `coin` | 422 `text/plain` `Failed to deserialize the JSON body into the target type` |
| `nSigFigs` 2, 3, 4, or 5; `nSigFigs: 5` + `mantissa` 2 or 5 | 200, keys `coin, time, levels`, **and `spread`** |
| `nSigFigs: null` | 200, ordinary book without `spread` |
| `nSigFigs` 1 or 6; `mantissa` without `nSigFigs: 5`; `nSigFigs: 5` + `mantissa: 1` | 500 `application/json`, body `null` |
| `nSigFigs: "5"` (string) | 422 |

Level `px` and `sz` always include a fractional part (`"86505.0"`, `"0.35"`) in both ordinary and aggregated books. The aggregated book aggregates the **entire** HL book (20 levels per side at the `nSigFigs` step). You cannot reproduce it by aggregating the 20 full-precision levels locally: depth and `spread` will differ.

**WS + REST fallback:** take top of book (`bestBid, bestAsk, mid, bidSz, askSz, time`) from WS. If the book is absent or older than the chosen freshness threshold, request REST `l2Book` (weight 2). If the book remains stale, make no decisions from it.

### Walk the Book: Reduce Entry to Available Depth

An IoC on a thin book can fill only partially without fanfare. Before entry:

```ts
const levels = isBuy ? book.asks : book.bids;
const avail = depthWithinLimit(levels, isBuy, limitPx);     // Σ sz across levels no worse than limitPx -> { size, notional }
if (avail.size < wantSize * 0.999) {                        // 0.1% rounding tolerance
  const safeSize = floorSz(avail.size * 0.95, szDecimals);  // 5% buffer: book moves between read and order
  if (safeSize <= 0 || safeSize * limitPx < 10) skip('insufficient book depth');
  else wantSize = safeSize;                                 // next cycle can fill the remainder
}
```

- Check **entries** only. A close is required at any depth, so the book does not gate it.
- No book available → trade as though there were no gate (fail open).
- Log an actual partial fill explicitly: the book may have moved between the read and the order.

---

## 8. `candleSnapshot`

### Request

```jsonc
// POST /info
{ "type": "candleSnapshot",
  "req": { "coin": "BTC", "interval": "1d", "startTime": 1756000000000, "endTime": 1757000000000 } }
// HIP-3: "coin": "xyz:SP500"—prefix in coin, NO dex field
```

- `startTime` and `endTime` are Unix milliseconds.
- Weight is heavy (20).
- Verified intervals: `1m`, `5m`, `15m`, `1h`, `4h`, `1d`. The minimum is `1m`; **the API does not provide second candles**. For more precise SL/TP simulation with minute or second data, build the latter yourself from WS `trades`/`l2Book`.
- **Errors (live read-only requests, 2026-09-22):** unknown coin → 500 `application/json` with body `null`; missing `startTime` → 422 `text/plain` `Failed to deserialize…`; interval absent from the SDK list (`"7m"`) → 422. `endTime` is optional: without it, bars through the current open bar are returned. Neighboring endpoints follow similar rules: `fundingHistory` without `startTime` → 422, with an unknown coin → 500 `null`; `recentTrades` with an unknown coin → 500 `null`.

### Candle Fields

| Field | Type | Meaning |
|---|---|---|
| `t` | number | bar **open** time, ms |
| `T` | number | bar close time, ms |
| `s` | string | coin |
| `i` | string | interval |
| `o`, `h`, `l`, `c` | **string** | prices; parse with `Number()` |
| `v` | **string** | volume |
| `n` | number | trade count |

When parsing, discard candles with non-finite `h/l` or `c <= 0`. A non-array response means an empty series (or `null` if degradation must be represented).

### Limits and History Depth

- **No more than about 5000 candles per call.** If `[startTime, endTime]` is wider, HL returns the **most recent** candles, not the oldest. Measurement on 2026-07-30: a `5m` request over 90 days → 5029 candles, all from the latest ~18 days.
- **Small-interval history is limited to roughly the latest 5000 bars:** `5m` ≈ 18 days, `15m` ≈ 52 days. The old end of the window is empty for small intervals. For a long backtest on a small timeframe, record and store candles yourself.
- `1h` over 180 days = 4320 candles; `4h` over 180 days = 1080. Both fit in one response.
- Candles are immutable history and can be cached for a long time (for example, a 1-hour TTL). Quantize the cache key, for example by hour, or “last N days” requests will always miss.

### Backtest Invariants

- **The final bar is open:** it represents the current day (or interval), “whatever has elapsed so far.” Making a decision from it means using a price that did not yet exist at decision time. **Discard the final open candle** (test `T > now` or `t + intervalMs > now`).
- **Define the window by time, not by bar count.** Daily xyz-equity series have weekend gaps. A “length minus N” slice would shift the window by days.
- **HIP-3 candle coverage is not guaranteed.** Mark an empty response for an xyz coin as `NO_DATA` and exclude it from calculations; do not substitute zeros.
- Add a couple of days of headroom to a daily-candle window for the open bar and gaps.
- **Do not evaluate a strategy with intrabar limit entries and exits on hourly candles.** Event order within a 1h bar is unknown, and any choice is a guess, usually favorable to the strategy. Intrabar rules need minute data: `1m` is HL's minimum interval, ≤5000 bars ≈ 3.5 days per call, and anything beyond that requires your own collection. Hourly bars are suitable only as a rough filter.

### Fetching a Long Window in Chunks

```ts
const intervalToMs = (iv: string) => {
  const m = iv.match(/^(\d+)([mhd])$/);           // weekly/monthly intervals are not supported here
  if (!m) throw new Error(`Bad interval: ${iv}`);
  const n = Number(m[1]);
  return m[2] === 'm' ? n * 60_000 : m[2] === 'h' ? n * 3_600_000 : n * 86_400_000;
};

async function fetchCandles(coin: string, interval: string, startTime: number, endTime: number) {
  const intervalMs = intervalToMs(interval);
  const windowMs = intervalMs * (5000 - 1);        // no chunk wider than 5000 bars
  const out: Candle[] = [];
  let cursor = startTime;
  while (cursor < endTime) {
    const end = Math.min(cursor + windowMs, endTime);
    // HIP-3: coin = 'xyz:SP500' as-is, WITHOUT dex (bare name + dex -> HTTP 500)
    const chunk = await postInfo<Candle[]>({
      type: 'candleSnapshot',
      req: { coin, interval, startTime: cursor, endTime: end },
    });
    if (Array.isArray(chunk)) out.push(...chunk);   // do NOT break on an empty chunk: old history is empty for small intervals
    cursor = end + 1;
    await sleep(80);
  }
  const byT = new Map<number, Candle>();
  for (const c of out) if (typeof c.t === 'number') byT.set(c.t, c); // deduplicate by t
  return [...byT.values()].sort((a, b) => a.t - b.t);
}
```

- Do not swallow chunk errors silently; log the status. Otherwise an error (for example, HTTP 500 for HIP-3 with a bare name plus `dex`) looks like “no data.”
- Breaking on the first empty chunk while paginating from old candles yields **zero data** at small intervals because the old edge of the window is empty.
- Use `AbortSignal.timeout(20_000)` on `fetch` so a stuck socket does not occupy a limiter slot.
- Read candle-derived values needed on the hot path synchronously from cache, without calling HL.

---

## 9. Spot

- **Names in `coin`** (`frontendOpenOrders`, `userFills`): a pair containing `/` (`PURR/USDC`) or indexed form `@<index>` (`@85`, `@107`, `@142`) for pairs without a human-readable name. Perps use a bare ticker (`BTC`) or dex-prefixed name (`xyz:TSLA`).
- **Detector:** `coin.includes('/') || coin.startsWith('@')`.
- **Spot asset id:** `10000 + pair index`.
- **Token identifier** (for `sendAsset` and similar calls) has the `name:tokenId` format from `spotMeta`. USDC: `USDC:0x6d1e7cde53ba9467b783cb7c530ce054` (verified 2026-06-06).
- **Spot and perps on one account.** The perp engine must filter out spot orders and **not touch** them.
- **Resting spot bids reserve the quote asset.** Spot limit bids (for example on `XXX/USDC`) hold USDC in `hold`, or in `spotHold` on a portfolio-margin account. Therefore `spotHold − Σ perp-equity` equals the reserve for open spot orders, and that difference stays constant while the orders rest.

---

## 10. Per-Dex Account Reads: Market-Data Concerns

- **`clearinghouseState` with `dex:'xyz'`** is a separate accounting context. Add its `accountValue` to equity: full equity = the sum across all dexes (`['', 'xyz', …]`). Per account per cycle, this costs `clearinghouseState` (2) + `spotClearinghouseState` (2, use `balances: []` on error) + the equivalent xyz read + cached metadata ≈ 4–6 weight.
- **HIP-3 position quirks** (medium confidence):
  - `position.coin` may arrive **without a prefix**: prepend `xyz:` when the name contains no `:`;
  - `szi` may be `"0"` or absent while signed notional is in `positionValue`. Parse as: side = `sign(szi ≠ 0 ? szi : positionValue)`; `notionalAbs = |positionValue| > 0 ? |positionValue| : |entryNtl|`; `size = |szi| > 0 ? |szi| : (entryPx > 0 ? notionalAbs / entryPx : notionalAbs)`. `entryPx` can also be `0`.
- **`frontendOpenOrders`** (weight 20): `side: 'B'` = buy/long, `'A'` = sell/short. Resting orders appear **only** among open orders, not in positions or fills.
  - Collect orders by looping over dexes with `Promise.allSettled` and return `{ orders, complete }`: after a partial failure, “zero orders” supports no conclusion.
  - Web frontends (trade.xyz and aggregators) show all dexes together, while a naive request without `dex` loses all HIP-3 activity.
- **`userFills`:** xyz coins have names such as `xyz:TSLA`. The `dex` parameter is ignored: one request without `dex` already returns fills for all dexes; a separate xyz request only doubles weight and duplicates fills when merged. Derive the “HIP-3” flag from the prefix.
- **WS `allDexsClearinghouseState`** contains each dex separately, with xyz positions in their own entry.
### `recentTrades`

`{"type":"recentTrades","coin":"<COIN>"}` returns the latest 10 trades for the coin.

- **Order and fields (live read-only request, 2026-09-23, BTC, mainnet):** exactly 10 elements, **newest first** (`time` descending; two trades in the same millisecond are adjacent). Each element has `coin`, `side` (`B`/`A`), `px`, `sz` (strings such as `"86393.0"`), `time`, `hash`, `tid`, and `users` (an array of two addresses). `tid` is not monotonic in time; it is not a counter and must not be used for sorting.
- **`hash` can be zero** (exactly `0x` followed by 64 zeros): in two responses on 2026-09-23, 5 and 3 of 10 trades respectively had zero hashes; the others had transaction hashes. Do not use `hash` as the trade-deduplication key; use `tid`.

---

## 11. Trading Hours for Tokenized Equities on `xyz`

### Mechanics

- Perps on US equities and indices on `xyz` (`TSLA`, `HOOD`, `SP500`, and others) use the **deployer's oracle**. Outside the US trading session, it freezes at the last print.
- The HL perp itself remains tradable, but orders execute against a **thin book at a stale price**, producing poor fills.
- **Client-side gate.** Block only **opening a new position**. The gate does not block increasing, reducing, or closing an existing position.
- **Always-on exceptions:** `xyz:CL` (oil, nearly around the clock on CME) trades 24/7 and is never closed by the gate, including Saturday 15:00Z and holiday nights. Apply the calendar only to equities and indices.

### Session Model

- “Open” means the extended session **04:00–20:00 ET** (pre-market + regular + after-hours), Monday–Friday, excluding NYSE holidays.
- Half-day: **04:00–13:00 ET**.
- Calculate ET with `Intl.DateTimeFormat` and `timeZone: 'America/New_York'`: EST/EDT transitions are automatic and require no external dependency.

```ts
const ALWAYS_ON = new Set(['xyz:CL']);
const FULL_HOLIDAYS = new Set([
  // 2026
  '2026-01-01', '2026-01-19', '2026-02-16', '2026-04-03', '2026-05-25',
  '2026-06-19', '2026-07-03', '2026-09-07', '2026-11-26', '2026-12-25',
  // 2027
  '2027-01-01', '2027-01-18', '2027-02-15', '2027-03-26', '2027-05-31',
  '2027-06-18', '2027-07-05', '2027-09-06', '2027-11-25', '2027-12-24',
]);
const HALF_DAYS = new Set(['2026-11-27', '2026-12-24', '2027-11-26']);

function isXyzSessionOpen(coin: string, now: Date): boolean {
  if (!coin.toLowerCase().startsWith('xyz:') || ALWAYS_ON.has(coin)) return true;
  const p = Object.fromEntries(
    new Intl.DateTimeFormat('en-US', {
      timeZone: 'America/New_York', hour12: false,
      year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short',
      hour: '2-digit', minute: '2-digit',
    }).formatToParts(now).map((x) => [x.type, x.value]),
  );
  if (p.weekday === 'Sat' || p.weekday === 'Sun') return false;
  const ymd = `${p.year}-${p.month}-${p.day}`;
  if (FULL_HOLIDAYS.has(ymd)) return false;
  const minutes = (Number(p.hour) % 24) * 60 + Number(p.minute);
  const close = HALF_DAYS.has(ymd) ? 13 * 60 : 20 * 60;
  return minutes >= 4 * 60 && minutes < close;
}
```

### NYSE Holidays (Full Closure)

| Year | Dates |
|---|---|
| 2026 | 01-01, 01-19 (MLK), 02-16 (Washington), 04-03 (Good Friday), 05-25 (Memorial), 06-19 (Juneteenth), 07-03 (Independence observed: July 4 is Saturday), 09-07 (Labor), 11-26 (Thanksgiving), 12-25 |
| 2027 | 01-01, 01-18, 02-15, 03-26, 05-31, 06-18 (observed), 07-05 (observed), 09-06, 11-25, 12-24 (Christmas observed: the 25th is Saturday) |

Half-days (13:00 ET close): 2026-11-27, 2026-12-24, 2027-11-26.

- **Update the table annually** because some dates move.
- For an unknown year, do not apply holidays and log one warning.

### Model Test Cases

| Moment | Expected |
|---|---|
| Tue 2026-06-02 03:00 EDT | false |
| Tue 2026-06-02 04:30 / 11:00 / 19:59 EDT | true |
| Tue 2026-06-02 20:30 EDT | false |
| Saturday | false |
| Fri 2026-07-03 (July 4 observed), 2026-12-25 | false |
| Fri 2026-11-27 (half-day) 11:00 EST / 14:00 EST | true / false |
| Wed 2026-11-25 14:00 EST | true |
| `xyz:CL` on Saturday 2026-06-06 15:00Z and 2026-07-03 03:00Z | true |

This is a **client-side calendar model**, not an API response. HL does not expose a “market closed” flag (see “Open Questions”).

---

## 12. Delisted, Isolated-Only, and Market Restrictions

| Flag / field | Where | Meaning | Action |
|---|---|---|---|
| `isDelisted: true` | `meta.universe[]` | trading is prohibited | do not place orders; return an explicit “coin is delisted” error |
| `onlyIsolated: true` | `meta.universe[]` | isolated margin only | call `updateLeverage` with `isCross: false`; `isCross: true` is rejected |
| `maxLeverage` | `meta.universe[]` | leverage ceiling | leverage is an integer with `1 ≤ lev ≤ maxLeverage`; higher values make `updateLeverage` fail |
| `assetToStreamingOiCap` | `perpDexs[i]` | dex asset registry with OI caps | use it to verify HIP-3 universe completeness |
| coin absent from map | — | delisting **or** stale/degraded metadata | status `unknown`; do not treat as delisted or close a position based on it |

- Without metadata (no `assetIndex` or `szDecimals`), a position can be neither opened nor closed.
- Metadata can be temporarily unavailable, for example because of a 429. In that case SKIP entry; treat a close as a retryable error.
- At bot startup, verify that every configured market exists in meta (“these markets do not exist on HL: …”) and that configured leverage does not exceed `maxLeverage`.

---

## 13. Coin Names Within HL

- **Collision after stripping a prefix.** `xyz:SPCX` and main-dex `SPCX` are **different** HL markets, but stripping the prefix gives the same name. Use the full dex-prefixed name as the market key; a bare name is ambiguous.
- **Same-looking ticker ≠ same asset.** HL simultaneously lists `GRAM` (formerly Toncoin, ~$1.43) and an unrelated `TON` (~$1.80): HL's `TON` ticker is not Toncoin. Matching an HL ticker by name to an external coin registry or price feed does not fail loudly; it silently selects the wrong asset.

---

## 14. Load and Caching: Decision Summary

| Data | Cache | Deduplication | Note |
|---|---|---|---|
| `meta` / `meta dex=xyz` | 5 min, stale-on-error, 30 s failure cooldown | single-flight per dex | one cache per process, or load once at startup |
| `allMids` (per dex) | 2 s | single-flight | one request per tick per dex |
| `l2Book` (per coin) | 2 s, including cached errors | single-flight per coin | fail open |
| `candleSnapshot` | ~1 h, key quantized by hour | in-flight per key | history is immutable |

**Metadata stampede (measured 2026-06-01).**

- Before: the WS-message parser called `getMeta()` on **every** message. At TTL expiry, a batch of concurrent messages all missed at once and each made its own heavy request. The stampede grows linearly with concurrent messages; in the measurement, metadata consumed most of the entire HL load.
- After: one cache + single-flight → ~1 `meta` and ~1 `meta xyz` every 5 minutes. Meta calls fell by roughly 90%, and total weight by roughly half.

---

## 15. Pitfalls

1. **Omitted `dex` from `frontendOpenOrders` / `clearinghouseState`** → an account trading entirely on xyz returns **exactly 0 orders** without `dex` → poll each dex separately, merge by full name (`xyz:AMD` ≠ `AMD`), and return a completeness flag.
2. **Double prefix `xyz:xyz:SNDK`.** HL began returning prefixed `universe[].name` in `meta dex=xyz`; code that adds the prefix itself misses metadata (“no meta for xyz:…” every tick) → **all** xyz orders silently stop being placed → `coin = name.startsWith('xyz:') ? name : 'xyz:' + name`. The change did not affect `allMids` or asset indices.
3. **Exact equality between meta and the `perpDexs` registry.** During the `XBI` listing (2026-08-22), meta already returned the new asset (116 versus 115 in `assetToStreamingOiCap`) while the exchange had not updated the registry → an equality check blocks almost all xyz trading until exchange synchronization, and restarting does not help → require **inclusion**: every registry asset exists in meta. Extra assets are a new listing and warrant one info log. Missing assets catch truncation and delisting.
4. **Hard-coded `perpDexIndex = 1` / offset `110000`** → if HL inserts a dex before xyz or `perpDexs` returns garbage, the order goes to another builder dex → resolve the index by name at startup and every refresh; fail closed on doubt.
5. **Truncated 200 response from `meta`** shifts indices → order goes to another instrument → double-read `meta` + `metaAndAssetCtxs`, enforce append-only, verify completeness against the registry.
6. **HIP-3 `candleSnapshot` with bare `coin` + `dex:'xyz'`** → HTTP 500; swallowing the chunk error silently puts xyz coins into `NO_DATA` → pass `coin: 'xyz:SP500'` without `dex`, and do not swallow errors.
7. **Backtest uses the open final candle** → decision uses a future price → discard the open bar and define the window by time (equities have weekend gaps).
8. **Assuming a wide candle window returns the start of the period** → the latest ~5000 bars arrive; at `5m`, a 90-day request yields ~18 days → account for history depth, store candles yourself, and do not `break` on an empty chunk.
9. **`meta` stampede at TTL expiry** → most weight is wasted → one cache per process + single-flight + failure cooldown.
10. **Stale cache after failure without updating a failure timestamp** → during prolonged HL degradation, every hot-path call (every WS message) retries a heavy meta request (5 attempts × weight 20) → keep `lastFailAt` + 30-second cooldown separately from `ts`.
11. **`allMids` without `dex` for an xyz coin** → “no mid” → SKIP order → request with `dex:'xyz'` and try both prefixed and bare keys.
12. **IoC with a narrow xyz limit** fails to cross the book → exit rejected → use a wide limit around mid for reduce-only HIP-3 exits.
13. **Leverage above the pair's `maxLeverage`** (10x to 50x on xyz) → `updateLeverage` rejected → clamp to `maxLeverage` and reduce notional proportionally.
14. **`isCross: true` for an isolated-only market** → rejected → inspect `onlyIsolated`; if absent for xyz, treat it as isolated.
15. **Reduced size not floored again to `szDecimals`** → `REJECTED` because notional is below $10 → floor and check the minimum after every size recalculation.
16. **Lot costs less than or near the minimum** (`xyz:STRC`: lot 0.1 ≈ $8.68) → small orders cannot be placed precisely → account for lot value when planning sizes.
17. **Moving by “N ticks” using a fixed tick** → when crossing 100/1000, movement changes 10x → calculate the tick at the target price.
18. **Opening xyz equities outside the session** → fill against a frozen oracle in a thin book → gate OPEN only to 04:00–20:00 ET, exempt `xyz:CL`, and update the holiday table annually.
19. **Coin absence from the asset map treated as delisting** → false position close or market shutdown → use three-state `listed/unlisted/unknown`.
20. **Bare coin name used as market key** → collision between `xyz:SPCX` and `SPCX`; two different HL markets merge → key by the full dex-prefixed name.
21. **Hard-coded dex list** → positions and orders on a new dex (`io`) are invisible → enumerate with `perpDexs`.
22. **Spot orders on the same account** enter perp accounting or get canceled by the perp bot → filter `/` and `@`.
23. **Malformed `allMids` (`Infinity`, ≤0, empty object)** accepted as a price → position can be left unprotected → validate strictly; otherwise return `null`.

---

## 16. Open Questions / Not Verified

- **Exact `candleSnapshot` weight.** It is treated here as heavy (20). Whether weight grows with the number of returned candles has not been measured.
- **Weights of `perpDexs` and `recentTrades`** have not been measured.
- **Candle intervals** other than `1m/5m/15m/1h/4h/1d` (for example weekly and monthly) were not tested; the §8 parser understands only `m/h/d`. HL-side support is not verified.
- **`1m` history depth** was not measured. For `5m/15m`, the ~5000-bar figure is an observation (medium confidence).
- **Integer prices and the 5-significant-figure rule.** Whether integer prices with more significant figures are allowed (relevant at prices ≥100,000) is not verified.
- **Spot price precision** (spot `pxDecimals`) and the `spotMeta` shape (tokens/universe) were not analyzed. Only pair names (`@index`, `BASE/QUOTE`), asset id `10000 + index`, and the USDC token id were verified.
- **`funding` and `openInterest`:** `assetCtxs` fields in `metaAndAssetCtxs` (funding, OI, mark/oracle price, and so on), `fundingHistory` requests (documented shape in `backtest-and-data.md` §4), and `predictedFundings` are not described here. The meaning and units of `cap` in `assetToStreamingOiCap` were not analyzed either.
- **HIP-3 position quirks** (`szi: "0"` with nonzero `positionValue`, unprefixed `coin`) have medium confidence and may reflect an old response format.
- **Trading hours** for other HIP-3 dexes and the complete list of always-on xyz symbols other than `CL` are unknown. HL exposes no explicit “session closed” flag. The model is client-side.
- **`l2Book` parameters** (`nSigFigs`, `mantissa`): accepted values and responses to invalid values were measured live on 2026-09-22 (§7). The meaning of aggregated-book `spread` (difference between best aggregated levels?) was not verified.
- **Unknown `dex` for market types** (2026-09-22): `meta`, `metaAndAssetCtxs`, `perpsAtOpenInterestCap`, `perpDexLimits`, `perpDexStatus`, and `clearinghouseState` → 500 `application/json`, body `null` (`Content-Length: 4`); `allMids` → 200 `null`. The earlier “500 without a body” note (2026-09-16) is outdated: `curl -i` on 2026-09-22 showed body `null` and type `application/json`, making the later observation more precise (the first measurement used `Invoke-RestMethod`, which throws on 500; the body was probably hidden).
- **`perpsAtOpenInterestCap`** (2026-09-22): array of coin names at the open-interest cap (9 main-dex coins); for `dex: 'xyz'`, `[]`.
- **Other per-dex requests:** the assumption that `clearinghouseState` and other user-data requests behave like `frontendOpenOrders` is confirmed for `clearinghouseState`, `frontendOpenOrders`, and `allMids`; `userFills` ignores dex. For other requests it remains an assumption.
- **Contradictions resolved by date:**
  - `universe[].name` format in `meta dex=xyz` (formerly bare, prefixed since 2026-06) → normalize both;
  - HIP-3 candles: `dex:'xyz'` + bare name (early note) versus prefixed name without `dex` (live verification 2026-07-29) → the latter is correct;
  - `allMids` TTL: 5 s (early note, 2026-06-01) → 2 s (later; rationale in §6);
  - `perpDexs` list: 9 → 10 (`mkts`, 2026-06-23) → 11 (`io`, 2026-09-07).

---

Verification dates appear in the text. The HL API changes, so recheck limits and response shapes.

---

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