---
title: "OI Liquidation Heat"
description: "Where the positions opened over the lookback would be liquidated, estimated from the market's open interest and painted behind the candles as a heatmap. It is…"
order: 88
section: "cookbook"
---

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

# OI Liquidation Heat

![Estimated liquidations painted behind the candles as a heatmap built from open interest, amber and red bands that end where price ran into them](/wrun/images/liquidation-heat-oi.png)

Where the positions opened over the lookback would be liquidated, estimated from the market's open interest and painted behind the candles as a heatmap. It is the model of the [Liquidation Map](liquidation-map.md) drawn through time: every bar keeps the same ledger of opened positions and folds it onto a map of 128 rows around the close, its rows on round prices so the bands run straight, and each column shows the estimate as it stood on that bar. The brighter a row, the more money a sweep there would force out. A level that price trades through is gone from that bar on, so the bands end where price ran into them, and a band that runs on to the right edge is money still waiting. Resting the pointer on a cell reads its dollars and its price.

The parts are an `oi.close` input and side-split `trades.volume` inputs read as positions ([Data sources](../core-concepts/data-sources.md)), an `out.grid` of 128 rows written cell by cell from `onBar()`, two data-only outputs that place the grid on the price axis, a `plot.heatmap` declaration over the grid ([Price canvases](../presentation/price-canvases.md)), and a label handle for the one sentence shown without open interest ([Drawing objects](../presentation/drawing-objects.md)). This is also the `liquidation-heat-oi` template: the **OI Liquidation Heat** card under **Order flow** in the editor's starter list, and it compiles as written.

## The wrun indicator

```typescript sample=liquidation-heat-oi
// OI Liquidation Heat: where the positions opened over the lookback would be liquidated, ESTIMATED from the market's
// open interest and sided volume, painted behind the candles as a heatmap. It is the Liquidation Map's model drawn
// through time: every bar keeps the same ledger and folds it onto a 128-row map around the close, its rows on round
// prices so the bands run straight, and each column shows the estimate as it stood on that bar. The brighter a row,
// the more money a sweep there would force out; a level price trades through is gone from that bar on, so the bands
// end where price ran into them.
//
// 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. 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.

section("Positions");
param.int("lookback", 672, { min: 96, max: 2000, label: "Lookback in bars", description: "Bars of opened positions kept in the map, in the chart's own bars; the map covers no more than the chart has loaded" });
param.number("maint_margin_pct", 0.5, { min: 0, max: 2, step: 0.1, label: "Maintenance margin in percent", description: "Maintenance margin as a percent of the position: the distance a liquidation sits short of the leverage's full move" });
section("Map");
param.number("range_pct", 5, { min: 2, max: 25, step: 0.5, label: "Map range in percent", description: "Map range: percent around the close, each side; the 128 rows split it on round prices, so the reach varies a little and a narrower range draws finer rows" });
legend({ title: "estimated, up to {{lookback}} bars" }); // the words after the name in the legend: up to, since a chart holding fewer bars builds from those
input("close", ohlcv.close); // the chart's own candles: the map's centre, 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("grid_low", none, overlay, { description: "The bottom edge of the map's lowest row on this bar" }); // data-only: the heatmap reads it
output("row_step", none, overlay, { description: "The map's row height in price on this bar" });
output("longs_near", none, overlay, { format: "usd", description: "Estimated long liquidations within 5% below the close, USD, every bar" }); // data-only: the Console and alerts read them
output("shorts_near", none, overlay, { format: "usd", description: "Estimated short liquidations within 5% above the close, USD, every bar" });
out.grid("heat", { rows: 128 }); // 128 data-only outputs heat_0..heat_127, one per row of the map in USD, written with out_heat(row, value)
string("note", { max_bytes: 80 }); // the one sentence shown when the market has no open interest
handles.label({ anchor: "top_right", align: "right", color: "#94a3b8", size: 12, style: "plain", safe_area: true });
plot.heatmap({ name: "liq_heat", grid: "heat", price_low: "grid_low", price_step: "row_step", palette: ["theme.bg", "#f8c00040", "#f8c000", "#f86800", "#ef4444"], scale: "linear", min: 0, auto_quantile: 0.98, opacity: 0.8, behind_candles: true, label: "Estimated liquidations", tooltip: "{{value:usd}} at {{price}}" });

// Leverage tiers the opened positions are spread over, and the share of new open interest each tier takes: the
// Liquidation Map's guess at a perpetual's crowd (most size at low leverage, a thin tail at 100x), so the two
// templates estimate the same levels.
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 ROWS = 128; // rows in the map, the grid's declared count
const MAX_LOOKBACK = 2000; // the ring is sized for the largest lookback setting
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 dropped
const rowUsd = new StaticArray<f64>(ROWS); // this bar's map: USD per row
const note = draw.label(0); // the one slate sentence; a handle allocates once at module load

let lookback = 672; // settings, read in onStart()
let mm = 0.005;
let rangeFrac = 0.05;
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 noteShown = false;
let gridLow: f64 = NaN; // this bar's map: the bottom edge of row 0 and the row height
let step: f64 = NaN;
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

function onStart(): void {
  lookback = i32(p_lookback());
  mm = p_maint_margin_pct() / 100.0;
  rangeFrac = p_range_pct() / 100.0;
}

function niceStep(raw: f64): f64 { // the nearest of 1, 2, 2.5 and 5 times a power of ten (in ratio terms): rows on round prices that line up bar to bar
  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 addToMap(price: f64, usd: f64): void { // a live level adds its USD to the row it falls in; off the map it waits for price to come near
  const pos = (price - gridLow) / step;
  if (pos >= 0.0 && pos < f64(ROWS)) rowUsd[i32(pos)] += usd;
}

// One pass over the ledger: the levels this bar traded through are gone, the rest fold onto this bar's map and,
// within 5% of the close, into the near sums.
function sweepAndFold(close: f64, high: f64, low: f64): void {
  const nearLo = close * 0.95;
  const nearHi = close * 1.05;
  longsNear = 0.0;
  shortsNear = 0.0;
  for (let r = 0; r < ROWS; r += 1) rowUsd[r] = 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;
    }
    addToMap(price, usd);
  }
}

function openPositions(base: i32, close: f64, high: f64, low: f64, dOi: 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 = isNaN(high) || isNaN(low) ? close : (high + low + close) / 3.0; // the bar's typical price
  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 (longUsd > 0.0) {
      if (longPrice >= nearLo) longsNear += longUsd;
      addToMap(longPrice, longUsd);
    }
    if (shortUsd > 0.0) {
      if (shortPrice <= nearHi) shortsNear += shortUsd;
      addToMap(shortPrice, shortUsd);
    }
  }
}

function scaleAll(factor: f64): void { // open interest fell: every live level shrinks in proportion, and the map with it
  const used = count * SLOTS;
  for (let i = 0; i < used; i += 1) if (levelUsd[i] > 0.0) levelUsd[i] *= factor;
  for (let r = 0; r < ROWS; r += 1) rowUsd[r] *= factor;
  longsNear *= factor;
  shortsNear *= factor;
}

function writeEmpty(): void { // no map on this bar: every cell empty, the grid unplaced
  out_grid_low(NaN);
  out_row_step(NaN);
  out_longs_near(NaN);
  out_shorts_near(NaN);
  for (let r = 0; r < ROWS; r += 1) out_heat(r, NaN);
}

function writeNote(): void { // on the live bar: the one sentence while the market has served no open interest, gone once it does
  if (!bar.isLast()) return;
  if (!oiSeen) {
    sb_clear();
    sb_text("No open interest on this market, so no liquidation estimate");
    note.set(16.0, 12.0).text(str_note_sb);
    noteShown = true;
  } else if (noteShown) {
    note.delete();
    noteShown = false;
  }
}

// onBar() runs once per bar: recycle the oldest bar's slots, sweep the ledger with the bar's range while folding the
// survivors onto this bar's map, fold the bar's open-interest change in (new positions at their liquidation prices,
// or every level scaled down), then write the map, its bottom edge, its row height and the near sums.
function onBar(): void {
  const close = bar.close();
  const high = bar.high();
  const low = bar.low();
  if (isNaN(close) || close <= 0.0) { // no price on this bar: no map, and the ledger waits for the next one
    writeEmpty();
    return;
  }
  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;
  }
  step = niceStep((close * rangeFrac * 2.0) / f64(ROWS));
  gridLow = (Math.floor(close / step) - f64(ROWS / 2)) * step; // a fixed lattice of round prices, the close in the middle row
  sweepAndFold(close, high, low);
  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, close, high, low, dOi);
      else if (dOi < 0.0 && prevOi > 0.0) scaleAll(oiNow / prevOi);
    }
    prevOi = oiNow;
  }
  writeNote();
  if (!oiSeen) {
    writeEmpty();
    return;
  }
  out_grid_low(gridLow);
  out_row_step(step);
  out_longs_near(longsNear);
  out_shorts_near(shortsNear);
  for (let r = 0; r < ROWS; r += 1) out_heat(r, rowUsd[r] > 0.0 ? rowUsd[r] : NaN); // NaN = an empty cell
}
```

## 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()`, and spread over four leverage tiers, 10x, 25x, 50x and 100x, weighted 40, 30, 20 and 10 percent in `tierLeverage` and `tierWeight`. Each tier is liquidated by an adverse move of one over its leverage less `maint_margin_pct`: a 10x long opened at 84,000 with the default 0.5% margin is liquidated about 9.5% lower, near 76,000, and a 10x short about 9.5% higher, near 92,000. A fall in open interest scales every live level down in proportion (`scaleAll`), the map and the near sums with it.

**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); once `lookback` bars are held, the oldest bar's slots are recycled, so a level older than the lookback drops. `sweepAndFold` walks the ledger once per bar: a long level is gone once the bar's low reaches it, a short level once the bar's high does, and every level left adds its dollars to the row it falls in. A level outside the map on this bar stays in the ledger and comes back when price moves toward it. Nothing fades with age: a level keeps its full size until a sweep, a fall in open interest or the lookback takes it.

**The map follows the close.** The 128 rows reach about `range_pct` each side of the close (5% by default). `niceStep()` snaps the row height to a round price (1, 2, 2.5 or 5 times a power of ten), so the rows sit on the same prices bar after bar and the bands run straight: 50 on BTC near 85,000, a reach of 3,200 each side, varying a little as the snap moves. `grid_low` is the bottom edge of row 0 on each bar, a whole number of rows below the close's row, and `row_step` every row's height in price; `plot.heatmap({ grid: "heat", price_low: "grid_low", price_step: "row_step" })` reads the two to place the grid, so the map moves with the close. `out_heat(row, value)` writes a row's dollars, and NaN leaves a cell empty. A narrower range draws finer rows and leaves the far tiers off the map until price comes near them.

**The scale.** The palette runs from the chart's background through a faint amber (`#f8c00040`, amber at a quarter strength, so it reads on a light chart too) and full amber and orange to red. `scale: "linear"` with `min: 0` keeps most cells dark and lets only the stacked rows glow, and `auto_quantile: 0.98` sets the top of the scale at the 98th percentile of every cell in the run, so one huge row does not wash out the rest. `behind_candles: true` keeps the bars on top, and the tooltip `{{value:usd}} at {{price}}` reads a cell as its dollars and its price.

**The near sums.** `longs_near` and `shorts_near` are data-only outputs written on every bar: the estimated long liquidations within 5% below the close and the short liquidations within 5% above it, in USD. They draw nothing; read them at the editor's Console prompt or set an alert on them.

**An estimate, beside two siblings.** Nothing here is a venue's own liquidation feed: read a band as "about this much, about here". [Liquidation Heat](liquidation-heat.md) builds its map from the candles alone: every bar seeds levels at its close weighted by its volume, and the levels fade with age, so it draws on any chart but knows nothing of positions opened or closed. The [Liquidation Map](liquidation-map.md) is this model, with the same tiers, margin, sweeps and lookback, docked on the price axis as a two-sided profile measured on the live bar only. This recipe keeps that ledger and draws it on every bar, behind the candles.

## Where it runs

Perpetual futures with open interest: Binance Futures and Hyperliquid perpetuals, at any interval. The lookback is counted in the chart's own bars, so 672 is a week at 15m and four weeks at 1h, and the map covers no more than the chart has loaded: a 1h chart holding 297 bars builds from those 297, which is why the legend reads "up to 672 bars". Every bar draws its own column, so the history shows how the estimate moved; 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)). 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 map stays empty and one line of words shows at the top right, `No open interest on this market, so no liquidation estimate`, in plain slate: the `note` label handle pinned to the pane's top right corner and right-aligned, clear of the price axis. `longs_near` and `shorts_near` read NaN there. Once the market serves open interest, the sentence goes and the map builds up from the first rise. On a market with open interest but without sided trades, every new position is split half longs and half shorts. A bar whose close is NaN writes an empty column and leaves the ledger as it was.

## Customize it

- **A longer or shorter memory.** `lookback` (672, 96 to 2000) is the bars of opened positions the map keeps, in the chart's own bars: 672 is a week at 15m and four weeks at 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, and a margin as wide as a tier's whole move leaves that tier out.
- **A finer or wider map.** `range_pct` (5, 2 to 25) is about how far around the close the map reaches each side; the 128 rows split it on round prices, so a narrower range draws finer rows and a wider one brings the 10x tier into view. `rows: 128` in `out.grid` is the most a grid holds; a smaller grid needs `ROWS` changed to match.
- **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. It is the Liquidation Map's mix, so the two estimate the same levels; change both to keep them agreeing.
- **A fixed scale.** Set `min` and `max` on the heatmap to compare days on one scale, or `floor` to hide the faintest cells.

## Run it

1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **OI Liquidation Heat** under **Order flow**.
2. Press **Run** on a perpetual with open interest, such as BTCUSDT on Binance Futures at 15m: the map paints behind the candles, bright where opened positions would be liquidated, and each band ends where price swept it. The legend reads "OI Liquidation Heat estimated, up to 672 bars".
3. Rest the pointer on a cell for the dollars at that price; 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

- [Price canvases](../presentation/price-canvases.md) for `out.grid`, `plot.heatmap` over a grid, `price_low` and `price_step`
- [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.label`, the `draw.label(id)` handle and pane anchors
- [Plotting](../presentation/plotting.md) for data-only outputs
