---
title: "Liquidation map"
description: "An estimate of where the market's open positions would be liquidated, drawn on the price axis as a two-sided profile. The profile docks on the right of the…"
order: 103
section: "cookbook"
---

<!-- source: docs/indicators/cookbook/liquidation-map.md; generated by packages/cli/scripts/gen-indicator-docs.ts, do not edit -->

# Liquidation map

![A two-sided liquidation profile docked against the price axis, amber rows above the price and orange rows below, two tagged dotted cluster lines and the cockpit card at the top left](/wrun/images/liquidation-map.png)

An estimate of where the market's open positions would be liquidated, drawn on the price axis as a two-sided profile. The profile docks on the right of the pane, against the price axis, split at a centre line. Amber rows above the price are where shorts would be liquidated (a liquidated short is a forced buy, so these rows are fuel for a move up); they grow to one side of the centre. Orange rows below the price are where longs would be liquidated (forced sells); they grow to the other side. A longer and brighter row means more money sits at that price, and every bar glows toward its tip. Rows that price has already traded through are gone, so the profile is thin where price has recently been and thick just beyond the recent highs and lows. Resting the pointer on a row reads its price and dollars. The largest rows on each side (three by default) run across the chart as thin dotted lines in the side's colour, each with a knockout tag that ends just left of the profile: the price, then the money at it ("63.4K  $84M"). A card at the top left, under the chart legend, reads "Estimated liquidations" in the cockpit look (corner brackets, monospace type, a faint glow): the largest row in range as the big line ("$84M at 63.4K", in the colour of its side), then the money within 5% below the price (longs, orange) and within 5% above it (shorts, amber). Resting the pointer on a number explains it. The legend reads "Liquidation Map estimated, 672 bars".

The parts are one `plot.levels` profile docked on the price axis with `baseline: "center"`, fed by one frame ([Docked profiles](../presentation/cards-frames-panels.md#docked-profiles)), an `oi.close` input and side-split `trades.volume` inputs ([Data sources](../core-concepts/data-sources.md)), line and label handles for the cluster lines, their tags and the one sentence shown without open interest ([Drawing objects](../presentation/drawing-objects.md)), and a `render.hud` of three tiles in the cockpit look ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards), [Looks](../presentation/hud-and-hover-cards.md#looks)). This is also the `liquidation-map` template: the **Liquidation Map** card under **Beyond the time axis** in the editor's starter list, and it compiles as written.

## The wrun indicator

```typescript sample=liquidation-map
// Liquidation Map: where the positions opened over the lookback would be liquidated, ESTIMATED from the market's
// open interest and sided volume, docked on the price axis as a two-sided profile. Rows above the price are the
// shorts' liquidation prices (a liquidated short is a forced buy), drawn in amber to one side of the dock's centre
// line; rows below the price are the longs' (a forced sell), in orange to the other side. The hotter a row, the
// brighter it paints, and every bar glows toward its tip. A level that price already traded through is gone, so the
// profile reads thin where price has been and thick where it has not. The largest rows on each side run across the
// chart as thin dotted lines with a knockout tag ("63.4K  $84M") on the price's side of its line, so a line at the
// pane's edge keeps its tag inside the pane, and a cockpit card at the top left reads the
// largest row in range as its headline and the money within 5% of the price on each side. Nothing here is a venue's
// own liquidation feed: it is a model of where the open positions sit, built from the open-interest change bar by bar.
//
// The model, per bar: a rise in open interest opens positions at the bar's typical price, split into longs and
// shorts by the bar's buy share of sided volume, and across four leverage tiers (10x, 25x, 50x, 100x) with fixed
// weights; each position's liquidation price follows from its leverage and the maintenance margin. A fall in open
// interest scales every live level down in proportion. A long level is swept when a later bar's low reaches it, a
// short level when a later bar's high does. Levels older than the lookback drop. The profile, lines and card are
// measured on the live bar only; history bars only keep the ledger.

section("Positions");
param.int("lookback", 672, { min: 96, max: 2000, label: "Lookback, bars", description: "Bars of opened positions kept in the map: 672 is a week on a 15m chart, four weeks on 1h" });
param.number("maint_margin_pct", 0.5, { min: 0, max: 2, step: 0.1, label: "Maintenance margin, percent", description: "Maintenance margin as a percent of the position: the distance a liquidation sits short of the leverage's full move" });
section("Profile");
param.number("range_pct", 12, { min: 3, max: 40, label: "Profile range, percent", description: "Profile range: percent around the close, each side" });
param.int("rows", 160, { min: 40, max: 160, label: "Profile rows", description: "Price rows in the docked profile (the step snaps to a round price, so the count varies a little)" });
section("Cluster lines");
param.int("clusters", 3, { min: 0, max: 3, label: "Cluster lines per side", description: "Largest rows per side drawn as lines across the chart with a tag; 0 = none" });
param.number("cluster_gap_pct", 0.5, { min: 0, max: 5, step: 0.1, label: "Cluster gap, percent", description: "Two cluster lines on the same side sit at least this far apart, percent of price, so their tags never stack" });
param.int("tag_inset_px", 352, { min: 0, max: 1200, label: "Tag inset, pixels", description: "Pixels in from the price axis where the cluster tags end: past the docked profile, so a tag never sits on a bar" });
legend({ title: "estimated, {{lookback}} bars" }); // the words after the name in the legend
input("close", ohlcv.close); // the chart's own candles: the grid, the entry price, and the sweeps
input("oi", oi.close, { missing: "nan", description: "Open interest in USD; NaN on a market without it (spot, FX, prediction markets), which blanks the map" });
input("buy", trades.volume, { side: "BUY", missing: "zero", description: "Buy volume of the bar: its share of sided volume is the share of new positions read as longs" });
input("sell", trades.volume, { side: "SELL", missing: "zero", description: "Sell volume of the bar" });
output("longs_near", none, overlay, { format: "usd", description: "Estimated long liquidations within 5% below the close, USD, every bar" }); // data-only: the card and the Console read them
output("shorts_near", none, overlay, { format: "usd", description: "Estimated short liquidations within 5% above the close, USD, every bar" });
output("largest_usd", none, overlay, { format: "usd", description: "The largest profile row within range, USD; live bar only" });
output("largest_price", none, overlay, { format: "price", description: "The price of the largest profile row; live bar only" });
output("largest_side", none, overlay, { description: "0 when the largest row is below the close (longs), 1 when above (shorts), 2 when the map is empty: the headline's colour index" });
string("tag", { max_bytes: 40 }); // one bounded slot the cluster tags are written through, one at a time
string("largest_text", { max_bytes: 32 }); // the card's headline, "$84M at 63.4K", written on the live bar
string("note", { max_bytes: 80 }); // the one sentence shown when the market has no open interest, through a label handle at the top right
const liq_rows = frame("liq_rows", { max_bytes: 32768 }); // the docked profile: one row per grid price, a colour and a hint per row

// The docked profile: zero mid-dock (baseline center), the shorts' rows growing one way and the longs' the other, each
// row in its own colour from the frame (amber above the price, orange below, brighter the more money sits there), every
// bar fading from a third strength at its root to full at its tip, bars at most 12 px tall so a zoomed-in chart reads
// as a ladder instead of slabs, a hover card per row in dollars.
plot.levels({ name: "liquidations", frame: liq_rows, dock: "right", width_frac: 0.22, baseline: "center", labels: false, color: "#f8c000", opacity: 1, thickness_px: 12, gradient: ["#f8c00055", "#f8c000"], format: "usd", hover: true });
// The cluster lines and their tags are handles (the module picks the rows each run): width-1 dotted lines across the
// whole chart, and knockout tags whose right edge sits a fixed inset left of the price axis, clear of the profile,
// each hanging on the price's side of its line (a short cluster's tag under its line, a long cluster's over it).
handles.line({ width: 1, line_style: "dotted", extend: "both" });
handles.label({ anchor: "right", align: "right", size: 11, style: "knockout", font_weight: "medium", padding: 4, color: "#f8c000" });
// The card, in the cockpit look (corner brackets, monospace type, a faint glow), under the chart legend at the top
// left: the largest row in range leads as the headline in the side's colour, then the money within 5% of the price on
// each side, one tile per side. Every tile reads the newest row, the live bar's.
render.hud("liq_card", {
  position: "top_left",
  look: "cockpit",
  title: "Estimated liquidations",
  columns: 2,
  safe_area: true,
  tiles: [
    tile.pill("Largest row in range", "largest_text", { headline: true, draw: "text", color_by: "largest_side", colors: ["#f86800", "#f8c000", "#94a3b8"] }),
    tile.value("Longs within 5%", "longs_near", { format: "usd", color: "#f86800", hint: "Estimated long liquidations within 5% below the price: forced sells if price falls there" }),
    tile.value("Shorts within 5%", "shorts_near", { format: "usd", color: "#f8c000", hint: "Estimated short liquidations within 5% above the price: forced buys if price rises there" }),
  ],
});

// Leverage tiers the opened positions are spread over, and the share of new open interest each tier takes: a
// guess at the mix of a perpetual's crowd (most size at low leverage, a thin tail at 100x). Named here so the
// profile's shape can be argued about in one place.
const TIERS = 4;
const SLOTS = TIERS * 2; // per bar: four long levels, then four short levels
function tierLeverage(tier: i32): f64 {
  return tier == 0 ? 10.0 : tier == 1 ? 25.0 : tier == 2 ? 50.0 : 100.0;
}
function tierWeight(tier: i32): f64 {
  return tier == 0 ? 0.4 : tier == 1 ? 0.3 : tier == 2 ? 0.2 : 0.1;
}

const MAX_LOOKBACK = 2000; // the ring is sized for the largest lookback setting
const MAX_ROWS = 240; // grid rows in the docked profile: the rows setting, up to 1.41x more when the step rounds down to the nearest round price
const MAX_CLUSTERS = 3;
const HEAT_STEPS = 12; // how many brightness steps a row's colour takes, from a third strength to full
const levelPrice = new StaticArray<f64>(MAX_LOOKBACK * SLOTS); // the ledger: one price and one USD size per level, SLOTS per bar
const levelUsd = new StaticArray<f64>(MAX_LOOKBACK * SLOTS); // 0 once swept or closed
const rowUsd = new StaticArray<f64>(MAX_ROWS); // the profile grid: USD per row
const rowBlocked = new StaticArray<bool>(MAX_ROWS); // rows already taken by a cluster line, and the rows within the gap around it
const shortHex = new StaticArray<string>(HEAT_STEPS); // the frame's row colours, quoted, built once in onStart(): amber at HEAT_STEPS alphas
const longHex = new StaticArray<string>(HEAT_STEPS); // orange at the same alphas
const HEX_DIGITS = "0123456789abcdef";
const AMBER = rgba(248, 192, 0, 255); // shorts' liquidations, above price: the lines' and tags' ink
const ORANGE = rgba(248, 104, 0, 255); // longs' liquidations, below price
const SLATE = rgba(148, 163, 184, 255); // the one sentence on a market without open interest
const lines: LineHandle[] = []; // handle objects allocate once; ids are one space across kinds
const tags: LabelHandle[] = [];
for (let i = 0; i < MAX_CLUSTERS * 2; i += 1) {
  lines.push(draw.line(i)); // 0..2 the short clusters, 3..5 the long clusters
  tags.push(draw.label(MAX_CLUSTERS * 2 + i));
}
const note = draw.label(MAX_CLUSTERS * 4); // the one line of words when the market has no open interest: a label handle pinned to the pane's top right corner, under the pane's corner buttons (a declared draw.label sits at a price, a render.label at top_right right-aligns under the price axis)

let lookback = 672; // settings, read in onStart()
let rangeFrac = 0.12;
let rows = 160;
let mm = 0.005;
let clusters = 3;
let gapFrac = 0.005;
let tagInset = 352.0;
let head = -1; // the ring: head is the newest bar, count the bars in use
let count = 0;
let prevOi: f64 = NaN; // the last finite open interest reading
let oiSeen = false; // the market served open interest at least once
let close: f64 = NaN;
let t: f64 = NaN;
let firstT: f64 = NaN; // the first bar's open time: where the cluster lines start
let longsNear = 0.0; // live long levels within 5% below the close, USD
let shortsNear = 0.0; // live short levels within 5% above the close, USD
let gridLo: f64 = NaN; // the profile grid, built on the live bar
let step: f64 = NaN;
let rowCount = 0;
let rowMax = 0.0; // the largest row in the grid: the brightness scale

// ── The ledger, kept on every bar ──
function sweepAndSum(high: f64, low: f64): void { // levels this bar traded through are gone; what remains near the close is summed
  const nearLo = close * 0.95;
  const nearHi = close * 1.05;
  longsNear = 0.0;
  shortsNear = 0.0;
  const used = count * SLOTS;
  for (let i = 0; i < used; i += 1) {
    const usd = levelUsd[i];
    if (usd <= 0.0) continue;
    const price = levelPrice[i];
    if (i % SLOTS < TIERS) { // a long level: swept once a bar's low reaches it
      if (low <= price) {
        levelUsd[i] = 0.0;
        continue;
      }
      if (price >= nearLo) longsNear += usd;
    } else { // a short level: swept once a bar's high reaches it
      if (high >= price) {
        levelUsd[i] = 0.0;
        continue;
      }
      if (price <= nearHi) shortsNear += usd;
    }
  }
}
function scaleAll(factor: f64): void { // open interest fell: every live level shrinks in proportion
  const used = count * SLOTS;
  for (let i = 0; i < used; i += 1) if (levelUsd[i] > 0.0) levelUsd[i] *= factor;
  longsNear *= factor;
  shortsNear *= factor;
}
function openPositions(base: i32, dOi: f64, high: f64, low: f64): void { // new open interest, placed at its liquidation prices
  const buy = in_buy();
  const sell = in_sell();
  const total = buy + sell;
  const longShare = total > 0.0 ? buy / total : 0.5;
  const entry = (high + low + close) / 3.0;
  const nearLo = close * 0.95;
  const nearHi = close * 1.05;
  for (let tier = 0; tier < TIERS; tier += 1) {
    const move = 1.0 / tierLeverage(tier) - mm; // the adverse move that liquidates this tier
    if (move <= 0.0) continue; // a margin wider than the tier's whole move: nobody holds that position
    const longPrice = entry * (1.0 - move);
    const shortPrice = entry * (1.0 + move);
    const longUsd = dOi * longShare * tierWeight(tier);
    const shortUsd = dOi * (1.0 - longShare) * tierWeight(tier);
    levelPrice[base + tier] = longPrice;
    levelUsd[base + tier] = longUsd;
    levelPrice[base + TIERS + tier] = shortPrice;
    levelUsd[base + TIERS + tier] = shortUsd;
    if (longPrice >= nearLo) longsNear += longUsd;
    if (shortPrice <= nearHi) shortsNear += shortUsd;
  }
}

// ── The docked profile: an evenly spaced grid of rounded prices around the close ──
function niceStep(raw: f64): f64 { // the nearest of 1, 2, 2.5, 5 times a power of ten (in ratio terms), so the rows read as round prices and stay near the rows setting
  const mag = Math.pow(10.0, Math.floor(Math.log(raw) / Math.LN10));
  const m = raw / mag;
  return (m < 1.414 ? 1.0 : m < 2.236 ? 2.0 : m < 3.536 ? 2.5 : m < 7.071 ? 5.0 : 10.0) * mag; // the cut points are the geometric midpoints
}
function priceDecimals(width: f64): i32 { // enough decimals that the rounded row prices stay evenly spaced when parsed (the docked grid is contiguous only within 0.1% of its step)
  const d = i32(Math.ceil(Math.log(2000.0 / width) / Math.LN10));
  return d < 2 ? 2 : d > 9 ? 9 : d;
}
function buildGrid(): bool {
  const lo = close * (1.0 - rangeFrac);
  const hi = close * (1.0 + rangeFrac);
  if (!(hi > lo) || !(lo > 0.0)) return false;
  step = niceStep((hi - lo) / f64(rows));
  gridLo = Math.floor(lo / step) * step;
  rowCount = i32(Math.floor((hi - gridLo) / step)) + 1;
  while (rowCount > MAX_ROWS) { // cannot happen with the settings' bounds; kept so the buffer is never overrun
    step *= 2.0;
    gridLo = Math.floor(lo / step) * step;
    rowCount = i32(Math.floor((hi - gridLo) / step)) + 1;
  }
  if (rowCount < 3) return false; // the engine needs three prices for a grid
  for (let r = 0; r < rowCount; r += 1) rowUsd[r] = 0.0;
  const used = count * SLOTS;
  for (let i = 0; i < used; i += 1) { // each live level adds its USD to the row it falls in
    const usd = levelUsd[i];
    if (usd <= 0.0) continue;
    const r = i32(Math.round((levelPrice[i] - gridLo) / step));
    if (r >= 0 && r < rowCount) rowUsd[r] += usd;
  }
  rowMax = 0.0;
  for (let r = 0; r < rowCount; r += 1) if (rowUsd[r] > rowMax) rowMax = rowUsd[r];
  return true;
}
function rowPrice(r: i32): f64 {
  return gridLo + f64(r) * step;
}
function heatStep(usd: f64): i32 { // 0 (empty or faint) .. HEAT_STEPS - 1 (the largest row), on a square-root ramp so mid-sized rows still read
  if (!(rowMax > 0.0) || usd <= 0.0) return 0;
  const k = i32(Math.round(Math.sqrt(usd / rowMax) * f64(HEAT_STEPS - 1)));
  return k < 0 ? 0 : k >= HEAT_STEPS ? HEAT_STEPS - 1 : k;
}
function writeProfile(): void { // prices ascending; a signed value per row (shorts positive above the close, longs negative below, 0 where empty so the grid stays whole); a colour per row by heat; a hint per filled row
  const decimals = priceDecimals(step);
  fb_clear();
  fb_text("{\"prices\":[");
  for (let r = 0; r < rowCount; r += 1) {
    if (r > 0) fb_text(",");
    fb_f64(rowPrice(r), decimals);
  }
  fb_text("],\"values\":[");
  for (let r = 0; r < rowCount; r += 1) {
    if (r > 0) fb_text(",");
    const usd = rowUsd[r];
    fb_f64(usd <= 0.0 ? 0.0 : rowPrice(r) > close ? usd : -usd, 0);
  }
  fb_text("],\"colors\":[");
  for (let r = 0; r < rowCount; r += 1) {
    if (r > 0) fb_text(",");
    const k = heatStep(rowUsd[r]);
    fb_text(rowPrice(r) > close ? shortHex[k] : longHex[k]);
  }
  fb_text("],\"tooltips\":[");
  for (let r = 0; r < rowCount; r += 1) {
    if (r > 0) fb_text(",");
    if (rowUsd[r] <= 0.0) fb_text("null");
    else fb_text(rowPrice(r) > close ? "\"Short liquidations here: forced buys\"" : "\"Long liquidations here: forced sells\"");
  }
  fb_text("]}");
  writeFrameBuffer(liq_rows);
}

// ── Text helpers ──
function sbCompactPrice(v: f64): void { // 84461.8 -> "84.5K", 2431.6 -> "2431.6", 0.13797 -> "0.13797"
  if (v >= 10000.0) sb_compact(v, 1);
  else sb_auto(v);
}
function sbMoney(usd: f64): void { // "$1.23B", "$84M", "$8.4M", "$640K", "$12.5K"
  sb_text("$");
  const v = Math.abs(usd);
  if (v >= 1.0e9) {
    sb_f64(v / 1.0e9, 2);
    sb_text("B");
  } else if (v >= 1.0e6) {
    sb_f64(v / 1.0e6, v >= 1.0e7 ? 0 : 1);
    sb_text("M");
  } else if (v >= 1.0e3) {
    sb_f64(v / 1.0e3, v >= 1.0e5 ? 0 : 1);
    sb_text("K");
  } else sb_f64(v, 0);
}
function hex2(n: i32): string { // two hex digits of a byte; onStart() only (it allocates)
  return HEX_DIGITS.charAt((n >> 4) & 15) + HEX_DIGITS.charAt(n & 15);
}

// ── The cluster lines: the largest rows on each side, at least cluster_gap_pct apart so the tags stay readable ──
function drawClusters(): void {
  for (let r = 0; r < rowCount; r += 1) rowBlocked[r] = false;
  const gapRows = i32(Math.ceil((close * gapFrac) / step)); // rows either side of a pick that the next pick skips
  for (let side = 0; side < 2; side += 1) { // 0: shorts above the close (amber); 1: longs below (orange)
    const ink = side == 0 ? AMBER : ORANGE;
    let drawn = 0;
    for (let j = 0; j < clusters; j += 1) {
      let best = -1;
      for (let r = 0; r < rowCount; r += 1) {
        if (rowBlocked[r] || rowUsd[r] <= 0.0) continue;
        const above = rowPrice(r) > close;
        if ((side == 0) != above) continue;
        if (best < 0 || rowUsd[r] > rowUsd[best]) best = r;
      }
      if (best < 0) break;
      for (let r = best - gapRows; r <= best + gapRows; r += 1) if (r >= 0 && r < rowCount) rowBlocked[r] = true; // this row and the gap around it are taken
      const price = rowPrice(best);
      const k = side * MAX_CLUSTERS + drawn;
      lines[k].set(firstT, price, t, price).color(ink);
      sb_clear();
      sbCompactPrice(price);
      sb_text("  ");
      sbMoney(rowUsd[best]);
      tags[k].set(tagInset, price).text(str_tag_sb).color(ink).valign(side == 0 ? VALIGN_TOP : VALIGN_BOTTOM); // toward the price: a line at the pane's edge keeps its tag inside
      drawn += 1;
    }
    for (let j = drawn; j < MAX_CLUSTERS; j += 1) { // fewer clusters than last time: the spare handles go
      lines[side * MAX_CLUSTERS + j].delete();
      tags[side * MAX_CLUSTERS + j].delete();
    }
  }
}
function clearDrawings(): void {
  for (let k = 0; k < MAX_CLUSTERS * 2; k += 1) {
    lines[k].delete(); // no-ops when never drawn
    tags[k].delete();
  }
}

function onStart(): void {
  lookback = i32(p_lookback());
  mm = p_maint_margin_pct() / 100.0;
  rangeFrac = p_range_pct() / 100.0;
  rows = i32(p_rows());
  clusters = i32(p_clusters());
  gapFrac = p_cluster_gap_pct() / 100.0;
  tagInset = f64(p_tag_inset_px());
  for (let k = 0; k < HEAT_STEPS; k += 1) { // the row colours: the app's flow pair at a third strength for an empty row, full for the largest
    const a = i32(Math.round((0.3 + 0.7 * f64(k) / f64(HEAT_STEPS - 1)) * 255.0));
    shortHex[k] = "\"#f8c000" + hex2(a) + "\"";
    longHex[k] = "\"#f86800" + hex2(a) + "\"";
  }
}

// onBar() runs once per bar: sweep the ledger with the bar's range, then fold the bar's open-interest change into it
// (new positions at their liquidation prices, or every level scaled down); the near sums as numbers on every bar;
// the profile, the cluster lines, the tags and the card on the live bar only.
function onBar(): void {
  close = bar.close();
  t = bar.time();
  const high = bar.high();
  const low = bar.low();
  if (isNaN(firstT) && !isNaN(t)) firstT = t;
  if (isNaN(close)) return;
  sweepAndSum(high, low);
  head = (head + 1) % lookback; // this bar's slots: the oldest bar's levels are recycled once the ring is full
  if (count < lookback) count += 1;
  const base = head * SLOTS;
  for (let s = 0; s < SLOTS; s += 1) {
    levelUsd[base + s] = 0.0;
    levelPrice[base + s] = NaN;
  }
  const oiNow = in_oi();
  if (!isNaN(oiNow)) { // a bar without a reading changes nothing: the last reading carries forward
    oiSeen = true;
    if (!isNaN(prevOi)) {
      const dOi = oiNow - prevOi;
      if (dOi > 0.0) openPositions(base, dOi, high, low);
      else if (dOi < 0.0 && prevOi > 0.0) scaleAll(oiNow / prevOi);
    }
    prevOi = oiNow;
  }
  out_longs_near(oiSeen ? longsNear : NaN);
  out_shorts_near(oiSeen ? shortsNear : NaN);
  if (!bar.isLast()) return;
  if (!oiSeen) { // the market served no open interest at all: the one sentence, top right, and nothing else
    clearDrawings();
    sb_clear();
    sb_text("No open interest on this market, so no liquidation estimate");
    note.set(16.0, 40.0).text(str_note_sb).anchor(ANCHOR_TOP_RIGHT).align(ALIGN_RIGHT).style(LabelStyle.Plain).fontWeight(FontWeight.Normal).color(SLATE);
    sb_clear();
    sb_text("No open interest");
    str_largest_text_sb();
    out_largest_side(2.0);
    return;
  }
  note.delete(); // no-op when never drawn
  if (!buildGrid()) {
    clearDrawings();
    return;
  }
  writeProfile();
  drawClusters();
  let best = -1; // the largest row within range, either side
  for (let r = 0; r < rowCount; r += 1) if (rowUsd[r] > 0.0 && (best < 0 || rowUsd[r] > rowUsd[best])) best = r;
  const largestUsd = best < 0 ? 0.0 : rowUsd[best];
  const largestPrice = best < 0 ? NaN : rowPrice(best);
  out_largest_usd(largestUsd);
  out_largest_price(largestPrice);
  out_largest_side(best < 0 ? 2.0 : largestPrice > close ? 1.0 : 0.0);
  sb_clear();
  if (best < 0) sb_text("No positions yet");
  else {
    sbMoney(largestUsd);
    sb_text(" at ");
    sbCompactPrice(largestPrice);
  }
  str_largest_text_sb();
}
```

## How it works

**Open interest is read as positions.** On every bar `in_oi()` (the `oi.close` input, in USD) is compared with the last finite reading; a bar without a reading changes nothing. A rise is new open interest opened at the bar's typical price (high, low and close averaged), split into longs and shorts by the bar's buy share of `in_buy()` and `in_sell()` (half and half where the venue serves no sided trades), and spread over four leverage tiers, 10x, 25x, 50x and 100x, weighted 40, 30, 20 and 10 percent in `tierLeverage` and `tierWeight`. Each tier's liquidation price follows from its leverage and `maint_margin_pct`: a 10x long opened at 84,000 with the default 0.5% margin sits about 9.5% lower. A fall in open interest scales every live level down in proportion (`scaleAll`). Nothing here is a venue's own liquidation feed: read the rows as "about this much, about here".

**The ledger is a ring.** `levelPrice` and `levelUsd` are two fixed arrays sized for the largest lookback, eight slots per bar (four long tiers, then four short tiers), indexed by bar; once `lookback` bars are held the oldest bar's slots are recycled, so levels older than the lookback drop. `sweepAndSum` walks the ledger first on every bar: a long level is gone once the bar's low reaches it, a short level once the bar's high does, and what remains within 5% of the close is summed into `longs_near` and `shorts_near`, data-only outputs written on every bar. The forming bar replays from the chart's snapshot, so the ring advances once per bar on live ticks too ([Repainting](../core-concepts/repainting.md)).

**One frame is the profile.** On the live bar only, `buildGrid` lays an evenly spaced grid of rounded prices across `range_pct` each side of the close (`niceStep` snaps the step to 1, 2, 2.5 or 5 times a power of ten, so the row count varies a little from `rows`; on BTC near 85,000 the step is 100), and every live level adds its dollars to the row it falls in. `writeProfile` builds the `liq_rows` frame in the frame buffer with `fb_clear`, `fb_text` and `fb_f64` and sends it with `writeFrameBuffer`: `prices` ascending, a signed value per row (the shorts' rows above the close positive, the longs' below negative, 0 where empty so the grid stays whole), a colour per row and a `tooltips` hint per filled row naming its side. `baseline: "center"` puts zero mid-dock, so the two sides are the sign of one value and grow away from each other; `dock: "right"` and `width_frac: 0.22` set the profile against the price axis; `thickness_px: 12` caps a bar's height so a zoomed-in chart reads as a ladder, not slabs; `hover: true` opens the chart's hover card on a row with its price, its dollars and the hint.

**Heat is the row's colour.** `heatStep` maps a row's dollars against the largest row (`rowMax`) onto twelve steps on a square-root ramp, so mid-sized rows still read, and `onStart` builds the twelve colours once: amber `#f8c000` and orange `#f86800` at alphas from 30% for an empty row to 100% for the largest. `opacity: 1` paints those alphas exactly, and `gradient: ["#f8c00055", "#f8c000"]` fades every bar from a third strength at its root to full at its tip while each row keeps its own colour.

**The cluster lines are handles.** `handles.line({ width: 1, line_style: "dotted", extend: "both" })` and `handles.label({ anchor: "right", align: "right", size: 11, style: "knockout", font_weight: "medium", padding: 4 })` declare the kinds; six `draw.line(id)` and six `draw.label(id)` handles are made once at the top, three per side. `drawClusters` picks the largest row on each side largest-first, blocks the rows within `cluster_gap_pct` of each pick so two tags never stack, then sets each line from the first bar's time to the live bar's time at the row's price and its tag `tag_inset_px` pixels in from the price axis at that price, the text built in the `tag` slot by `sbCompactPrice` and `sbMoney` ("63.4K  $84M"). Each tag hangs on the price's side of its line, `valign(VALIGN_TOP)` under a short cluster's line and `valign(VALIGN_BOTTOM)` over a long cluster's, so a cluster whose line sits on the pane's top or bottom edge keeps its tag inside the pane. Spare handles are deleted when a side has fewer clusters than last time.

**The card reads slots and outputs.** `render.hud("liq_card", { position: "top_left", look: "cockpit", title: "Estimated liquidations", columns: 2, safe_area: true, tiles: [...] })` sits under the legend. The headline is a `tile.pill` with `draw: "text"` reading the `largest_text` slot ("$84M at 63.4K", written on the live bar), coloured by `largest_side`: 0 orange when the largest row is below the close (longs), 1 amber when above (shorts), 2 slate when the map is empty. Two `tile.value` tiles read `longs_near` and `shorts_near` in `format: "usd"`, each with a `hint` sentence on its hover card. `largest_usd` and `largest_price` are data-only outputs written on the live bar for anything that reads them, and `legend({ title: "estimated, {{lookback}} bars" })` prints the lookback after the name.

## Where it runs

Perpetual futures with open interest: Binance Futures and Hyperliquid perpetuals, at any interval (BTCUSDT on Binance Futures and the PURR perpetual on Hyperliquid at 15m and 1h in the builds). The lookback is counted in bars, so 672 is a week at 15m and four weeks at 1h. The profile, the lines and the card are measured on the live bar, so they follow the forming bar; history bars only keep the ledger. The lines live in chart time and the tags in pane pixels, so on a coin whose 1h view spans a wide range the rows are thin and a tag can sit over candles; `tag_inset_px` moves the tags. No market refuses the run: the `oi` input declares `missing: "nan"`, so a market without open interest reads NaN and gets the sentence below instead of a toast.

## When data is missing

On a market without open interest (spot pairs, FX, prediction markets) the indicator shows one line of words at the top right, `No open interest on this market, so no liquidation estimate`, in plain slate: the `note` label handle pinned 40 px under the pane's top right corner and right-aligned, clear of the price axis and of the pane's corner buttons. The card's headline reads "No open interest" in slate, so the card is not an empty frame, the two value tiles print a dash (`longs_near` and `shorts_near` read NaN until the market has served open interest at least once), and it draws nothing else: no profile, no lines. On a market with open interest but without sided trades, every new position is split half longs and half shorts. While no live level falls inside the profile's range the headline reads "No positions yet" and there is no row to tag.

## Customize it

- **A longer or shorter memory.** `lookback` (672, 96 to 2000) is the bars of opened positions the map keeps, counted in the chart's bars: 672 is a week on a 15m chart and four weeks on 1h. `maint_margin_pct` (0.5, 0 to 2) is the maintenance margin as a percent of the position: a larger margin moves every liquidation price closer to its entry.
- **A wider or finer profile.** `range_pct` (12, 3 to 40) is the percent around the price the profile covers each side; `rows` (160, 40 to 160) the price rows in it, the step snapping to a round price so the count varies a little.
- **More or fewer lines.** `clusters` (3, 0 to 3) is the largest rows per side drawn across the chart, 0 for none; `cluster_gap_pct` (0.5, 0 to 5) is how far apart two lines on one side must sit, percent of price, so their tags never stack; `tag_inset_px` (352, 0 to 1200) is where the tags end, in from the price axis: raise it on a wide pane where the tags land on the longest rows.
- **Another crowd.** The tier mix, 10x, 25x, 50x and 100x at 40, 30, 20 and 10 percent, is a guess at a perpetual's crowd, named in `tierLeverage` and `tierWeight` at the top of the file so the profile's shape can be argued about in one place; a venue's own leverage distribution would replace it.
- **Change the look.** The card is `look: "cockpit"`, a home look that keeps its dark paper on a light chart; the Look row on the indicator's Style page switches it without code ([The Style page](../settings/style-page.md#the-look-row)), and glass is the other look this example was shot in. The row switches the card only: the profile, the lines and the tags keep their amber and orange inks.

## Run it

1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Liquidation Map** under **Beyond the time axis**.
2. Press **Run** on a perpetual with open interest, such as BTCUSDT on Binance Futures at 15m: the two-sided profile docks against the price axis, the cluster lines cross the chart with their tags, and the cockpit card appears at the top left under the legend. Rest the pointer on a row to read its price and dollars.
3. At the editor's Console prompt, type `last 20 longs_near` to read the estimated long liquidations within 5% below the price on the last 20 bars.

## Concepts used

- [Docked profiles](../presentation/cards-frames-panels.md#docked-profiles) for `plot.levels`, `baseline: "center"`, `thickness_px`, `gradient`, `hover`, the frame's `prices`, `values`, `colors` and `tooltips`, and the `fb_*` frame buffer on the same page
- [Data sources](../core-concepts/data-sources.md) for the `oi` and `trades` sources, `side` and the `missing` policies
- [Drawing objects](../presentation/drawing-objects.md) for `handles.line`, `handles.label`, the `draw.line(id)` and `draw.label(id)` handles, pane anchors and the knockout style
- [HUD cards](../presentation/hud-and-hover-cards.md#hud-cards) for `render.hud`, its anchors and `safe_area`
- [Looks](../presentation/hud-and-hover-cards.md#looks) for `look: "cockpit"` and what it draws
- [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles) for the headline `tile.pill` with `draw: "text"` and `tile.value` with `hint`
- [The Style page](../settings/style-page.md#the-look-row) for the Look row that switches the look without code
- [Text formatting](../functions/text-formatting.md) for `sb_compact`, `sb_auto` and `sb_f64` behind the tags and the headline
