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
- Every HIP-3 dex is a separate universe. Without a
dexfield,meta,metaAndAssetCtxs,allMids,clearinghouseState, andfrontendOpenOrdersreturn only the main perp dex.xyzmarkets appear only in requests withdex: 'xyz'. Forgettingdexmakes an account that trades only on xyz look “empty.” The exception isuserFills/userFillsByTime: thedexparameter is ignored (verified 2026-06-11: responses with and withoutdex:'xyz'were identical and theirtidsets matched). One request withoutdexalready includesxyz:*fills; a second request doubles weight and duplicates fills when merged. Identify xyz by thecoinprefix. - Asset id: main perp = index in
meta.universe; spot =10000 + index; HIP-3 =100000 + perpDexIndex × 10000 + index in that dex's universe. Forxyz,perpDexIndex = 1currently, soxyz:TSLA = 110001andxyz:SP500 = 110052. ResolveperpDexIndexby name from{type:'perpDexs'}; do not hard-code it. - HIP-3 market names are prefixed:
xyz:TSLA. HL changed the format:metawithdex:'xyz'now returnsuniverse[].namealready prefixed, whereas it used to return a bare name. Normalize idempotently or you will producexyz:xyz:TSLA, silently preventing every xyz order from being placed. - Spot in the
coinfield:PURR/USDCor@<index>(@107,@142). Filter withcoin.includes('/') || coin.startsWith('@'). - Precision. Lot size =
10^-szDecimals(szDecimalsfrom meta, 0..8). Perp prices obey two limits: no more than 5 significant figures and no more than6 − szDecimalsdecimal places. When price crosses a power of 10, the tick changes by 10x. Minimum order notional is $10. 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.- HIP-3 candles: use
{coin:'xyz:SP500'}withoutdex.{coin:'SP500', dex:'xyz'}returns HTTP 500 (verified 2026-07-29). metais 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).- 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. - Info-request weights:
allMids,l2Book,clearinghouseState, andspotClearinghouseStatecost 2.meta,metaAndAssetCtxs,frontendOpenOrders, andcandleSnapshotcost 20. Fetch theallMidsmap 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:
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)
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[]].universeandassetCtxshave equal lengths.- For a HIP-3 dex, add
dex: 'xyz'to both requests. - A main-dex coin's asset index (field
ain an order,assetinupdateLeverage, andaincancel) is the element's position inuniverse. - 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
coinbut has a numericasset, resolve the name asuniverse[asset].namefrom metadata for the same dex. Whileuniverseis 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:
szDecimalsis a safe integer in0..8(“HL perp sizes currently use 0..8 decimals”);maxLeverageis1..1000;onlyIsolated, if present, is boolean;nameis at most 128 characters and contains no control characters; duplicate names are errors;universelength: 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:
- Double read. Request
metaandmetaAndAssetCtxsin parallel and comparename,szDecimals,maxLeverage, andonlyIsolatedelement by element. Also requireuniverse.length === assetCtxs.length. Any mismatch invalidates the entire universe. Cost: 40 weight. - Append-only updates. In a new map, every previously verified asset must keep the same
assetIndexandszDecimals. If an asset disappears or changes index orszDecimals, reject the update and retain the last verified map. For execution, existing assets may only be appended to, never changed. - HIP-3 universe completeness against the
perpDexsregistry. Theuniversereturned bymetaforxyzmust contain every asset from that dex'sassetToStreamingOiCap. 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
szDecimalsalmost 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
lastFailAtseparately from cachetsso 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.
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 (
perpDexsdoes not contain exactly onexyz, 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. IfperpDexsis 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 yieldsunknown, notunlisted, 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 |
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
szDecimalsand recheck the $10 minimum, or the reduced size will returnREJECTED.
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 examplexyz:SPCX. This is genuinely a different market, not the same asSPCXon 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
agentEnableDexAbstractionaction. 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
// 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
perpDexIndexused in the asset-id formula. - The array has length ≥2 and element 0 is
null. Internalnullgaps can also occur; they are part of the schema and preserve indices. Test withd && typeof d.name === 'string'and buildMap<name, index>from matching entries. capinassetToStreamingOiCaparrives as a string or number.- An empty
assetToStreamingOiCapis 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
perpDexscannot be read, do not build HIP-3 metadata. If the required dex is absent fromperpDexs, 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 frommetawithdex:'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.
maxLeveragevaries widely:xyz:HOOD = 10x,xyz:SP500 = 50x. Leverage above the maximum makesupdateLeveragefail. Leverage is an integer ≥1:lev = Math.min(Math.max(1, Math.floor(want)), meta.maxLeverage); reduce notional proportionally.onlyIsolatedistruefor 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 useonlyIsolated = u.onlyIsolated === true. Therefore setisCross = !onlyIsolatedinupdateLeverage.
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.
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
dexcontains neitherSKHXnorxyz:SKHX. HIP-3 requires{type:'allMids', dex:'xyz'}, where the key isxyz:SKHX. Bare keys have also been observed, so try both forms:mids[coin] ?? mids[bare]. - Maps from several dexes can be merged into one
Mapkeyed 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@Nkeys plusPURR/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 bodynull(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 uncheckedInfinitycould 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
allMidsrequests (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
allMidscalls (every tick) are normal at weight 2 when cached and deduplicated.
7. l2Book
// 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
]
}
pxandszarrive as strings;nis the number of orders at the level.levels[0][0]is best bid andlevels[1][0]is best ask.mid = (bestBid + bestAsk) / 2.- For HIP-3, pass
coinin 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
l2Bookframe has the same shape (data.coin,data.levels,data.time).
Parsing:
null, missinglevels, non-arraylevels, 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:
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
// POST /info
{ "type": "candleSnapshot",
"req": { "coin": "BTC", "interval": "1d", "startTime": 1756000000000, "endTime": 1757000000000 } }
// HIP-3: "coin": "xyz:SP500"—prefix in coin, NO dex field
startTimeandendTimeare Unix milliseconds.- Weight is heavy (20).
- Verified intervals:
1m,5m,15m,1h,4h,1d. The minimum is1m; the API does not provide second candles. For more precise SL/TP simulation with minute or second data, build the latter yourself from WStrades/l2Book. - Errors (live read-only requests, 2026-09-22): unknown coin → 500
application/jsonwith bodynull; missingstartTime→ 422text/plainFailed to deserialize…; interval absent from the SDK list ("7m") → 422.endTimeis optional: without it, bars through the current open bar are returned. Neighboring endpoints follow similar rules:fundingHistorywithoutstartTime→ 422, with an unknown coin → 500null;recentTradeswith an unknown coin → 500null.
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: a5mrequest 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. 1hover 180 days = 4320 candles;4hover 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 > nowort + 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_DATAand 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:
1mis 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
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)onfetchso 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
sendAssetand similar calls) has thename:tokenIdformat fromspotMeta. 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 inhold, or inspotHoldon a portfolio-margin account. ThereforespotHold − Σ perp-equityequals the reserve for open spot orders, and that difference stays constant while the orders rest.
10. Per-Dex Account Reads: Market-Data Concerns
clearinghouseStatewithdex:'xyz'is a separate accounting context. Add itsaccountValueto equity: full equity = the sum across all dexes (['', 'xyz', …]). Per account per cycle, this costsclearinghouseState(2) +spotClearinghouseState(2, usebalances: []on error) + the equivalent xyz read + cached metadata ≈ 4–6 weight.- HIP-3 position quirks (medium confidence):
position.coinmay arrive without a prefix: prependxyz:when the name contains no:;szimay be"0"or absent while signed notional is inpositionValue. Parse as: side =sign(szi ≠ 0 ? szi : positionValue);notionalAbs = |positionValue| > 0 ? |positionValue| : |entryNtl|;size = |szi| > 0 ? |szi| : (entryPx > 0 ? notionalAbs / entryPx : notionalAbs).entryPxcan also be0.
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.allSettledand 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
dexloses all HIP-3 activity.
- Collect orders by looping over dexes with
userFills: xyz coins have names such asxyz:TSLA. Thedexparameter is ignored: one request withoutdexalready 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
allDexsClearinghouseStatecontains 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 (
timedescending; two trades in the same millisecond are adjacent). Each element hascoin,side(B/A),px,sz(strings such as"86393.0"),time,hash,tid, andusers(an array of two addresses).tidis not monotonic in time; it is not a counter and must not be used for sorting. hashcan be zero (exactly0xfollowed 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 usehashas the trade-deduplication key; usetid.
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.DateTimeFormatandtimeZone: 'America/New_York': EST/EDT transitions are automatic and require no external dependency.
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
assetIndexorszDecimals), 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:SPCXand main-dexSPCXare 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$1.80): HL'sTON(TONticker 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
metaand ~1meta xyzevery 5 minutes. Meta calls fell by roughly 90%, and total weight by roughly half.
15. Pitfalls
- Omitted
dexfromfrontendOpenOrders/clearinghouseState→ an account trading entirely on xyz returns exactly 0 orders withoutdex→ poll each dex separately, merge by full name (xyz:AMD≠AMD), and return a completeness flag. - Double prefix
xyz:xyz:SNDK. HL began returning prefixeduniverse[].nameinmeta 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 affectallMidsor asset indices. - Exact equality between meta and the
perpDexsregistry. During theXBIlisting (2026-08-22), meta already returned the new asset (116 versus 115 inassetToStreamingOiCap) 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. - Hard-coded
perpDexIndex = 1/ offset110000→ if HL inserts a dex before xyz orperpDexsreturns garbage, the order goes to another builder dex → resolve the index by name at startup and every refresh; fail closed on doubt. - Truncated 200 response from
metashifts indices → order goes to another instrument → double-readmeta+metaAndAssetCtxs, enforce append-only, verify completeness against the registry. - HIP-3
candleSnapshotwith barecoin+dex:'xyz'→ HTTP 500; swallowing the chunk error silently puts xyz coins intoNO_DATA→ passcoin: 'xyz:SP500'withoutdex, and do not swallow errors. - 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).
- 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 notbreakon an empty chunk. metastampede at TTL expiry → most weight is wasted → one cache per process + single-flight + failure cooldown.- 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 fromts. allMidswithoutdexfor an xyz coin → “no mid” → SKIP order → request withdex:'xyz'and try both prefixed and bare keys.- 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.
- Leverage above the pair's
maxLeverage(10x to 50x on xyz) →updateLeveragerejected → clamp tomaxLeverageand reduce notional proportionally. isCross: truefor an isolated-only market → rejected → inspectonlyIsolated; if absent for xyz, treat it as isolated.- Reduced size not floored again to
szDecimals→REJECTEDbecause notional is below $10 → floor and check the minimum after every size recalculation. - 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. - Moving by “N ticks” using a fixed tick → when crossing 100/1000, movement changes 10x → calculate the tick at the target price.
- 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. - Coin absence from the asset map treated as delisting → false position close or market shutdown → use three-state
listed/unlisted/unknown. - Bare coin name used as market key → collision between
xyz:SPCXandSPCX; two different HL markets merge → key by the full dex-prefixed name. - Hard-coded dex list → positions and orders on a new dex (
io) are invisible → enumerate withperpDexs. - Spot orders on the same account enter perp accounting or get canceled by the perp bot → filter
/and@. - Malformed
allMids(Infinity, ≤0, empty object) accepted as a price → position can be left unprotected → validate strictly; otherwise returnnull.
16. Open Questions / Not Verified
- Exact
candleSnapshotweight. It is treated here as heavy (20). Whether weight grows with the number of returned candles has not been measured. - Weights of
perpDexsandrecentTradeshave 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 onlym/h/d. HL-side support is not verified. 1mhistory depth was not measured. For5m/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 thespotMetashape (tokens/universe) were not analyzed. Only pair names (@index,BASE/QUOTE), asset id10000 + index, and the USDC token id were verified. fundingandopenInterest:assetCtxsfields inmetaAndAssetCtxs(funding, OI, mark/oracle price, and so on),fundingHistoryrequests (documented shape inbacktest-and-data.md§4), andpredictedFundingsare not described here. The meaning and units ofcapinassetToStreamingOiCapwere not analyzed either.- HIP-3 position quirks (
szi: "0"with nonzeropositionValue, unprefixedcoin) 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
CLare unknown. HL exposes no explicit “session closed” flag. The model is client-side. l2Bookparameters (nSigFigs,mantissa): accepted values and responses to invalid values were measured live on 2026-09-22 (§7). The meaning of aggregated-bookspread(difference between best aggregated levels?) was not verified.- Unknown
dexfor market types (2026-09-22):meta,metaAndAssetCtxs,perpsAtOpenInterestCap,perpDexLimits,perpDexStatus, andclearinghouseState→ 500application/json, bodynull(Content-Length: 4);allMids→ 200null. The earlier “500 without a body” note (2026-09-16) is outdated:curl -ion 2026-09-22 showed bodynulland typeapplication/json, making the later observation more precise (the first measurement usedInvoke-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); fordex: 'xyz',[].- Other per-dex requests: the assumption that
clearinghouseStateand other user-data requests behave likefrontendOpenOrdersis confirmed forclearinghouseState,frontendOpenOrders, andallMids;userFillsignores dex. For other requests it remains an assumption. - Contradictions resolved by date:
universe[].nameformat inmeta dex=xyz(formerly bare, prefixed since 2026-06) → normalize both;- HIP-3 candles:
dex:'xyz'+ bare name (early note) versus prefixed name withoutdex(live verification 2026-07-29) → the latter is correct; allMidsTTL: 5 s (early note, 2026-06-01) → 2 s (later; rationale in §6);perpDexslist: 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.
© markpaper authors. Licensed under CC BY 4.0: when publishing or adapting this work, credit “markpaper — Hyperliquid knowledge base” and link to the original and the license.