Backtest liquidation clusters with heatmap history

Hit rates: how often price reached the big liquidation cluster near it within 24 hours.

Cheapest plan
Free
Time to build
45 min
Endpoints
2
/heatmap/{coin} · /candles/{coin}
Languages
2
Node 18+, Python 3.9+, no installs

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

  1. 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"
  2. 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"
  3. 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.

  4. 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.

  5. 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.mjs

Download: 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

FreeCheapest
Every coin over the last 24 hours in 15m, 1h columns: enough to try the script, too short for a backtest.
10/min, 1,000/day, 30 days of history
Pro$29/mo
Every coin, 365 days, 5m, 15m, 1h, 4h, 12h, 1d columns. Personal research.
20/min, 5,000/day, 365 days of history
Desk$79/mo
1095 days, 1m columns, and the hit-rate lab on the site with filters and export.
30/min, 10,000/day, 1095 days of history
API Dev$49/mo
The same depth as Pro for research inside your organisation.
60/min, 50,000/day, 365 days of history

Compare plans

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

heatmap-fetch

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.

README.md · heatmap.mjs · package.json

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

LiveHyperliquid markets 11sOperationalHyperliquid positions & whales 11sOperationalLiquidation heatmap & levels 16sOperationalLighter markets & liquidations 10sOperationalLighter positions & heatmap 15sOperationalLighter RH positions & heatmap 13sOperationalBinance markets 1mOperationalBybit markets 30sOperationalBinance liquidations 39sOperationalBybit liquidations 39sOperationaldYdX positions & heatmap 17sOperationalGMX positions & heatmap 21sOperationalLong/short & open interest history 22sOperationalOrder books & trade flow 14sOperationalOn-chain transfers 14sOperationalSecurity monitor 1mOperationalBlockmetric Sentiment 10mOperationalOfficial news 8mOperationalAlert delivery 11sOperational10:06:57 UTCData: Blockmetric Research · Binance · Bybit