"""Research without a notebook: do liquidation flushes mark local turning points? Pulls the hourly liquidation series of a coin (GET /liquidations/summary) and its 1h candles (GET /candles/{coin}), joins them by hour and compares the forward returns after "flush" hours (long or short liquidations in the top percentile of the window) with the returns after every hour. Standard library only (csv, statistics) plus the blockmetric SDK: runs in a terminal, a cron job or CI, no notebook needed. BLOCKMETRIC_API_KEY=bm_live_... BLOCKMETRIC_BASE_URL=https:///api/v1 \ python research.py --coin BTC --days 30 --percentile 95 --csv btc-liq-research.csv The window is capped by your plan's history (free key: 30 days). Results are descriptive statistics over a short sample, not a trading signal and not investment advice. """ import argparse import csv import statistics import sys from datetime import datetime, timedelta, timezone from typing import IO, Any, Dict, List, Mapping, Optional, Sequence from blockmetric import Blockmetric, BlockmetricError HORIZONS = (1, 4, 24) # forward return horizons in hours def _hour(iso: str) -> str: """Normalises an ISO time to its UTC hour, e.g. '2026-10-26T13:00:00Z'.""" t = datetime.fromisoformat(iso.replace("Z", "+00:00")).astimezone(timezone.utc) return t.replace(minute=0, second=0, microsecond=0).strftime("%Y-%m-%dT%H:00:00Z") def fetch(client: Blockmetric, coin: str, days: int) -> List[Dict[str, Any]]: """Hourly rows {t, longUsd, shortUsd, close} for the last `days` days, oldest first (hours without both are left out).""" hours = days * 24 summary = client.liquidation_summary(coin=coin, hours=hours)["data"] liq = {_hour(r["t"]): r for r in summary["series"] if r["longUsd"] is not None and r["shortUsd"] is not None} start = (datetime.now(timezone.utc) - timedelta(hours=hours + max(HORIZONS))).strftime("%Y-%m-%dT%H:00:00Z") closes: Dict[str, float] = {} candles = client.candles(coin, tf="1h", from_=start, limit=min(5000, hours + max(HORIZONS) + 1))["data"]["candles"] for k in candles: closes[_hour(k["t"])] = float(k["close"]) return [{"t": t, "longUsd": float(r["longUsd"]), "shortUsd": float(r["shortUsd"]), "close": closes[t]} for t, r in sorted(liq.items()) if t in closes] def forward_returns(rows: Sequence[Mapping[str, Any]], horizons: Sequence[int] = HORIZONS) -> List[Dict[str, Any]]: """Adds ret_h = close(t + h) / close(t) - 1 (None near the end of the window).""" by_t = {r["t"]: r["close"] for r in rows} out = [] for r in rows: t = datetime.fromisoformat(r["t"].replace("Z", "+00:00")) row = dict(r) for h in horizons: later = by_t.get((t + timedelta(hours=h)).strftime("%Y-%m-%dT%H:00:00Z")) row[f"ret_{h}h"] = later / r["close"] - 1 if later else None out.append(row) return out def percentile(values: Sequence[float], p: float) -> float: """p-th percentile (0-100) with linear interpolation.""" xs = sorted(values) if not xs: return 0.0 k = (len(xs) - 1) * p / 100 lo, hi = int(k), min(int(k) + 1, len(xs) - 1) return xs[lo] + (xs[hi] - xs[lo]) * (k - lo) def study(rows: Sequence[Mapping[str, Any]], pct: float = 95, horizons: Sequence[int] = HORIZONS) -> Dict[str, Dict[str, Any]]: """Mean / median forward returns after long flushes, short flushes and over all hours.""" long_cut = percentile([r["longUsd"] for r in rows], pct) short_cut = percentile([r["shortUsd"] for r in rows], pct) groups = { "all hours": list(rows), f"long flush (>= ${long_cut:,.0f})": [r for r in rows if r["longUsd"] >= long_cut and r["longUsd"] > 0], f"short flush (>= ${short_cut:,.0f})": [r for r in rows if r["shortUsd"] >= short_cut and r["shortUsd"] > 0], } out: Dict[str, Dict[str, Any]] = {} for name, g in groups.items(): stats: Dict[str, Any] = {"hours": len(g)} for h in horizons: rets = [r[f"ret_{h}h"] for r in g if r.get(f"ret_{h}h") is not None] stats[f"mean_{h}h"] = statistics.fmean(rets) if rets else None stats[f"median_{h}h"] = statistics.median(rets) if rets else None stats[f"up_{h}h"] = sum(1 for x in rets if x > 0) / len(rets) if rets else None out[name] = stats return out def render(result: Mapping[str, Mapping[str, Any]], horizons: Sequence[int] = HORIZONS) -> str: def pct(x: Optional[float]) -> str: return "-" if x is None else f"{x * 100:+.2f}%" def share(x: Optional[float]) -> str: return "-" if x is None else f"{x * 100:.0f}%" head = f"{'group':<32}{'hours':>6}" + "".join(f"{f'mean {h}h':>11}{f'up {h}h':>8}" for h in horizons) lines = [head, "-" * len(head)] for name, s in result.items(): lines.append(f"{name:<32}{s['hours']:>6}" + "".join(f"{pct(s[f'mean_{h}h']):>11}{share(s[f'up_{h}h']):>8}" for h in horizons)) return "\n".join(lines) def write_csv(rows: Sequence[Mapping[str, Any]], out: IO[str], horizons: Sequence[int] = HORIZONS) -> None: cols = ["t", "longUsd", "shortUsd", "close"] + [f"ret_{h}h" for h in horizons] w = csv.DictWriter(out, fieldnames=cols, extrasaction="ignore", lineterminator="\n") w.writeheader() for r in rows: w.writerow({k: ("" if r.get(k) is None else r[k]) for k in cols}) def main(argv: Optional[Sequence[str]] = None, client: Optional[Blockmetric] = None) -> int: p = argparse.ArgumentParser(description="Forward returns after liquidation flushes (hourly).") p.add_argument("--coin", default="BTC") p.add_argument("--days", type=int, default=30, help="look-back in days (capped by your plan's history)") p.add_argument("--percentile", type=float, default=95, help="a flush is an hour above this percentile (default 95)") p.add_argument("--csv", help="also write the joined hourly data to this file") args = p.parse_args(argv) try: c = client or Blockmetric(user_agent="research-example/1.0") rows = forward_returns(fetch(c, args.coin, args.days)) except BlockmetricError as e: print(f"error: {e}" + (f"\n see {e.doc_url}" if e.doc_url else ""), file=sys.stderr) return 1 if not rows: print(f"No hourly data for {args.coin} in the window.", file=sys.stderr) return 1 print(f"{args.coin}: {len(rows)} hours from {rows[0]['t']} to {rows[-1]['t']}; flush = top {100 - args.percentile:g} % of hours\n") print(render(study(rows, args.percentile))) print("\nDescriptive statistics over a short sample, not a trading signal, not investment advice.") if args.csv: with open(args.csv, "w", newline="", encoding="utf-8") as f: write_csv(rows, f) print(f"hourly data written to {args.csv}") return 0 if __name__ == "__main__": sys.exit(main())