Backtest liquidation clusters with heatmap history
Hit rates: how often price reached the big liquidation cluster near it within 24 hours.
What you get
A backtest over the heatmap history: for every hourly column it takes the largest cluster of long and of short liquidation prices within 3 % of the mark and checks with the candles whether price reached it within the next 24 hours. The result is a hit rate per side and the median time to reach.
BTC: clusters of at least $5M within 3 % of the mark, 129 hourly columns since 2026-10-23
side tested reached hit rate median hours to reach
longs 105 22 21 % 12
shorts 105 11 10 % 14
Horizon 24 h. Longs = clusters of long liquidations below the mark, shorts = above it.Real output of the program below, run against our API on 2026-10-28 10:38 UTC.
Build it step by step
Fetch the heatmap over time
One column per hour: times, prices (the price buckets), longCells and shortCells (USD that would be liquidated per bucket) and the mark per column. hasSnapshot says which columns hold data.
curl -s -H "Authorization: Bearer $BLOCKMETRIC_API_KEY" \ "$BLOCKMETRIC_BASE_URL/heatmap/BTC?tf=1h&hours=336"Fetch the candles of the same window
Hourly OHLC from the first column on. The high and low of each hour decide whether price touched a level.
curl -s -H "Authorization: Bearer $BLOCKMETRIC_API_KEY" \ "$BLOCKMETRIC_BASE_URL/candles/BTC?tf=1h&from=2026-10-14T00:00:00Z&limit=5000"Pick the clusters to test
Per column: the largest long cluster below the mark and the largest short cluster above it, within MAX_DISTANCE and at least MIN_USD. Columns whose horizon has not passed yet are skipped.
Count hits
A long cluster is reached when a later hourly low is at or below its price, a short cluster when a high is at or above it. Write every test to CSV with CSV=tests.csv and slice it any way you like.
Go further back
Repeat with end=<start of the previous window> to cover more history, as far as your plan reaches.
The complete backtest
Set this once in the shell that runs the code:
export BLOCKMETRIC_API_KEY=bm_live_… # create one on /account/api
export BLOCKMETRIC_BASE_URL=https://api.blockmetric.io/v1// Backtest liquidation clusters: how often did the price reach the largest liquidation cluster near it within the next
// hours? Node 18+, no dependencies. Reads the heatmap history (GET /heatmap/{coin}) and the candles (GET /candles/{coin})
// of the same window and checks every hourly column.
//
// export BLOCKMETRIC_API_KEY=bm_live_... # free key: every coin, the last 24 hours (DAYS=1); Pro: every coin, 1 year
// export BLOCKMETRIC_BASE_URL=https://<domain>/api/v1
// node backtest.mjs
//
// Options: COIN (BTC), DAYS (14, at most 30 with tf=1h), MIN_USD (5000000: clusters of at least $5M),
// MAX_DISTANCE (0.03: clusters within 3 % of the mark), HORIZON_HOURS (24), CSV (file name for every test, optional).
// Descriptive statistics over a short window, not a trading signal.
import fs from 'node:fs'
const BASE = (process.env.BLOCKMETRIC_BASE_URL ?? '').replace(/\/+$/, '')
const KEY = process.env.BLOCKMETRIC_API_KEY
if (!BASE) throw new Error('Set BLOCKMETRIC_BASE_URL, e.g. https://<domain>/api/v1')
const COIN = process.env.COIN ?? 'BTC'
const DAYS = Math.min(30, Number(process.env.DAYS ?? 14))
const MIN_USD = Number(process.env.MIN_USD ?? 5_000_000)
const MAX_DISTANCE = Number(process.env.MAX_DISTANCE ?? 0.03)
const HORIZON = Number(process.env.HORIZON_HOURS ?? 24)
async function api(path, params = {}) {
const url = new URL(BASE + path)
for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v))
const res = await fetch(url, { headers: KEY ? { Authorization: `Bearer ${KEY}` } : { 'X-Blockmetric-Access': 'public' }, signal: AbortSignal.timeout(60_000) })
const body = await res.json()
if (!res.ok) throw new Error(`${res.status} ${body.error?.code}: ${body.error?.message}`)
return body
}
/** The largest cluster per side of one column within maxDistance of the mark (null when none is big enough). */
export function nearestBig(h, col) {
const mark = h.mark[col]
const best = { long: null, short: null }
h.prices.forEach((price, row) => {
const dist = (price - mark) / mark
if (Math.abs(dist) > MAX_DISTANCE) return
// Longs liquidate below the mark, shorts above it.
const side = price < mark ? 'long' : 'short'
const usd = side === 'long' ? h.longCells[col][row] : h.shortCells[col][row]
if (usd >= MIN_USD && (!best[side] || usd > best[side].usd)) best[side] = { side, price, usd, dist }
})
return [best.long, best.short].filter(Boolean)
}
async function main() {
const { data: h } = await api(`/heatmap/${encodeURIComponent(COIN)}`, { tf: '1h', hours: DAYS * 24 })
const { data: c } = await api(`/candles/${encodeURIComponent(COIN)}`, { tf: '1h', from: h.times[0], limit: 5000 })
const byHour = new Map(c.candles.map((k) => [k.t, k]))
const tests = []
h.times.forEach((t, col) => {
if (h.hasSnapshot?.[col] === false || !h.mark[col]) return
const start = Date.parse(t)
if (start + (HORIZON + 1) * 3_600_000 > Date.now()) return // the horizon has not passed yet
for (const cl of nearestBig(h, col)) {
let hitAfter = null
for (let i = 1; i <= HORIZON && hitAfter === null; i++) {
const k = byHour.get(new Date(start + i * 3_600_000).toISOString())
if (k && (cl.side === 'long' ? k.low <= cl.price : k.high >= cl.price)) hitAfter = i
}
tests.push({ time: t, side: cl.side, price: cl.price, usd: cl.usd, distance: cl.dist, hitAfter })
}
})
console.log(`${COIN}: clusters of at least $${MIN_USD / 1e6}M within ${MAX_DISTANCE * 100} % of the mark, ${h.times.length} hourly columns since ${h.times[0].slice(0, 10)}`)
console.log('side tested reached hit rate median hours to reach')
for (const side of ['long', 'short']) {
const s = tests.filter((x) => x.side === side)
const hits = s.filter((x) => x.hitAfter !== null).map((x) => x.hitAfter).sort((a, b) => a - b)
const rate = s.length ? `${((hits.length / s.length) * 100).toFixed(0)} %` : '-'
const median = hits.length ? String(hits[Math.floor(hits.length / 2)]) : '-'
console.log(`${(side + 's').padEnd(7)} ${String(s.length).padStart(6)} ${String(hits.length).padStart(9)} ${rate.padStart(10)} ${median.padStart(6)}`)
}
console.log(`Horizon ${HORIZON} h. Longs = clusters of long liquidations below the mark, shorts = above it.`)
if (process.env.CSV) {
fs.writeFileSync(process.env.CSV, ['time,side,price,usd,distance,hit_after_hours', ...tests.map((x) => `${x.time},${x.side},${x.price},${x.usd},${x.distance.toFixed(5)},${x.hitAfter ?? ''}`)].join('\n') + '\n')
console.log(`Wrote ${tests.length} tests to ${process.env.CSV}`)
}
}
main().catch((e) => {
console.error(e.message)
process.exit(1)
})
Run it:
node backtest.mjs
python backtest.py
COIN=ETH MIN_USD=2000000 HORIZON_HOURS=12 CSV=eth.csv node backtest.mjsDownload: backtest.mjs · backtest.py · requests.sh. MIT-0: use it as you like.
Real responses
What the API answered when we ran this recipe, with long lists shortened.
Heatmap over time (grid shortened): GET /heatmap/BTC?tf=1h&hours=24
{
"data": {
"coin": "BTC",
"tf": "1h",
"bucketSize": 500,
"times": [
"2026-10-27T10:00:00.000Z",
"2026-10-27T11:00:00.000Z",
"2026-10-27T12:00:00.000Z",
"… 21 more"
],
"prices": [
57000,
57500,
58000,
"… 95 more"
],
"longCells": [
[
2909439,
143220144,
606403,
"… 95 more"
],
[
3528295,
142129321,
605773,
"… 95 more"
],
[
2708264,
141975934,
607716,
"… 95 more"
],
"… 21 more"
],
"shortCells": [
[
0,
0,
0,
"… 95 more"
],
[
0,
0,
0,
"… 95 more"
],
[
0,
0,
0,
"… 95 more"
],
"… 21 more"
],
"mark": [
79744,
79924,
79893,
"… 21 more"
]
},
"meta": {
"updatedAt": "2026-10-28T09:50:00.000Z",
"sources": [
"hyperliquid"
],
"plan": "api_dev",
"delayMinutes": 0,
"attribution": {
"text": "Data by Blockmetric · Source: Hyperliquid",
"url": "https://blockmetric.io/"
},
"license": {
"id": "internal",
"attributionRequired": false
}
}
}Rate limits and pagination
- tf=1h covers at most 720 hours per call; page back in time with end=<ISO time> for longer studies.
- Two requests per window (heatmap and candles), so even a year of hourly columns is about 26 requests.
- A column shows the levels at its start (the first snapshot inside it), never a sum: test from the next hour on, as the program does.
- Compare against a baseline (random levels at the same distance) before reading too much into a hit rate.
- Always pass tf; without it the API keeps an older default and end is not available.
Common pitfalls
- Adjacent buckets belong to one cluster: count one level per side and column, or the same move is counted many times.
- Hits are not profits. A cluster that was reached may have reversed price or run through it.
Who it is for
- Quant traders testing liquidation levels as targets or stops
- Researchers who want numbers instead of chart impressions
- Anyone deciding whether the heatmap fits their strategy
Plan and limits
Honest limits
- The heatmap is built from tracked Hyperliquid positions (venue=lighter for tracked Lighter accounts), so it shows real liquidation prices of those positions, not of every trader.
- History starts when our collection started and is limited by your plan; earlier columns are gaps, not zeros.
- Descriptive statistics over a short window, not a trading signal.
Example projects
Fetches the liquidation heatmap of a coin (where tracked Hyperliquid positions would be liquidated, over time) and prints the largest clusters above the mark (short liquidations) and below it (long liquidations). With --csv it also writes the whole grid as long-form CSV (time,price,long_usd,short_usd,mark) for your own charts or backtests.
These projects use the official SDKs, which are being prepared for release. Until then the dependency-free program above runs as it is.
Next steps
More recipes like this
/funding/spreads/funding+2Research notebook in PythonA reproducible study: what happened after the hours with the largest liquidations.Free30 min to build/liquidations/summary/candles/{coin}+1