// Research: do hours with unusually large liquidations ("flushes") mark turning points? Node 18+, no dependencies. // The JavaScript twin of research.py: joins the hourly liquidation series (GET /liquidations/summary) with 1h candles // (GET /candles/{coin}) and compares the forward returns after flush hours with all hours. // // export BLOCKMETRIC_API_KEY=bm_live_... # free key: 30 days of history; Pro or API Dev: 1 year // export BLOCKMETRIC_BASE_URL=https:///api/v1 // node research.mjs // // Options: COIN (BTC), DAYS (30), PERCENTILE (95), CSV (write the joined table, optional). 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:///api/v1') const COIN = process.env.COIN ?? 'BTC' const DAYS = Number(process.env.DAYS ?? 30) const PERCENTILE = Number(process.env.PERCENTILE ?? 95) const HOURS = [1, 4, 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 } const summary = await api('/liquidations/summary', { coin: COIN, hours: DAYS * 24 }) const series = summary.data.series // [{ t, longUsd, shortUsd }], starts at coveredSince const { data } = await api(`/candles/${encodeURIComponent(COIN)}`, { tf: '1h', from: series[0].t, limit: 5000 }) const close = new Map(data.candles.map((c) => [c.t, c.close])) for (const w of summary.meta.warnings ?? []) console.log(`note: ${w.message}`) const rows = series.flatMap((s, i) => { if (!close.has(s.t) || s.longUsd === null || s.shortUsd === null) return [] // null = an hour that was not collected const row = { t: s.t, long_usd: s.longUsd, short_usd: s.shortUsd, close: close.get(s.t) } for (const h of HOURS) { const later = series[i + h]?.t row[`ret_${h}h`] = later && close.has(later) ? close.get(later) / close.get(s.t) - 1 : null } return [row] }) const cut = (key) => { const v = rows.map((r) => r[key]).sort((a, b) => a - b) return v.length ? v[Math.min(v.length - 1, Math.floor((v.length * PERCENTILE) / 100))] : 0 } const longCut = cut('long_usd') const shortCut = cut('short_usd') const groups = { 'all hours': rows, [`long flush (>= $${(longCut / 1e6).toFixed(1)}M)`]: rows.filter((r) => longCut > 0 && r.long_usd >= longCut), [`short flush (>= $${(shortCut / 1e6).toFixed(1)}M)`]: rows.filter((r) => shortCut > 0 && r.short_usd >= shortCut), } console.log(`${COIN}, ${rows.length} hours since ${rows[0]?.t.slice(0, 10) ?? '-'}, flush = top ${100 - PERCENTILE} % of hours`) console.log(`${'group'.padEnd(30)} ${'hours'.padStart(5)}${HOURS.map((h) => ` ${`mean ${h}h`.padStart(9)} ${`up ${h}h`.padStart(6)}`).join('')}`) for (const [name, g] of Object.entries(groups)) { const cells = HOURS.map((h) => { const r = g.map((x) => x[`ret_${h}h`]).filter((x) => x !== null) if (!r.length) return ` ${'-'.padStart(9)} ${'-'.padStart(6)}` const mean = r.reduce((a, b) => a + b, 0) / r.length return ` ${`${(mean * 100).toFixed(2)}%`.padStart(9)} ${`${((r.filter((x) => x > 0).length / r.length) * 100).toFixed(0)}%`.padStart(6)}` }) console.log(`${name.padEnd(30)} ${String(g.length).padStart(5)}${cells.join('')}`) } console.log(summary.meta.attribution.text) if (process.env.CSV && rows.length) { const cols = Object.keys(rows[0]) fs.writeFileSync(process.env.CSV, [cols.join(','), ...rows.map((r) => cols.map((c) => r[c] ?? '').join(','))].join('\n') + '\n') console.log(`wrote ${process.env.CSV}`) }