# Liquidation magnets

> Mark the price levels where liquidation cascades fired, keep them on the chart until price returns, and plot realised liquidations underneath.

A liquidation cascade leaves a mark on the market: a burst of forced orders at one price, often the end of a move. Traders watch those levels afterwards, because price tends to come back and test where the leverage was flushed.

This script watches the liquidations of each bar. When one side's liquidations are many times their recent average, it marks the bar's extreme as a level: long liquidations at the low (longs are liquidated as price falls), short liquidations at the high. Each level extends to the right with its size until price trades back into it; then the line stops at that bar. A pane shows the liquidations themselves, longs below zero and shorts above.

```flowscope title="liquidation-magnets.fs"
script "Liquidation magnets"

input (
  avgLen = input.int(100, title: "Average length (bars)", min: 10, max: 1000)
  burstMult = input.float(4.0, title: "Burst (x average)", min: 1.5, max: 50.0)
  minUsd = input.float(1_000_000.0, title: "Smallest burst (USD)", min: 0.0)
)

data (
  chart = subscribe(data.ohlcv)
  stats = subscribe(data.stat)
)

type Pocket {
  key: int
  price: float
  usd: float
  longs: bool
  start: time
  open: bool
}

type Burst {
  side: string
  price: float
  usd: float
}

state (
  pockets = Pocket[](40)
  count = 0
  lines = entities.linePool(max: 60)
)

pane liqPane = pane(title: "Liquidations (USD)", height: 0.2)

plot (
  longLiqs = plot.histogram(title: "Longs liquidated", color: color.red, on: liqPane)
  shortLiqs = plot.histogram(title: "Shorts liquidated", color: color.green, on: liqPane)
)

alert burstAlert = alert(title: "Liquidation burst", onClose: true, payload: Burst)

fn label(usd: float, longs: bool) -> string {
  return str.format("{0} liqs {1:,.1}M", longs ? "Long" : "Short", usd / 1_000_000.0)
}

on chart.close {
  // A sell liquidates a long, a buy liquidates a short.
  let longUsd = stats.sellLiq ?? 0.0
  let shortUsd = stats.buyLiq ?? 0.0
  longLiqs.plot(0.0 - longUsd)
  shortLiqs.plot(shortUsd)
  let longAvg = ta.sma(longUsd, avgLen)
  let shortAvg = ta.sma(shortUsd, avgLen)

  let t = chart.time
  let h = chart.high
  let l = chart.low
  if t == null || h == null || l == null {
    return
  }

  // Close the levels price has traded back into.
  for i, p in pockets {
    if p.open && t > p.start && l <= p.price && h >= p.price {
      pockets[i] = Pocket { key: p.key, price: p.price, usd: p.usd, longs: p.longs, start: p.start, open: false }
      let tone = p.longs ? color.red : color.green
      lines.get(p.key).set(p.start, p.price, t, p.price, width: 1.0, color: color.withAlpha(tone, 120), style: linestyle.dotted, text: label(p.usd, p.longs))
    }
  }

  let longBurst = longAvg != null && longUsd >= minUsd && longUsd > longAvg * burstMult
  let shortBurst = shortAvg != null && shortUsd >= minUsd && shortUsd > shortAvg * burstMult
  if longBurst || shortBurst {
    let longs = longBurst && longUsd >= shortUsd
    let usd = longs ? longUsd : shortUsd
    let price = longs ? l : h
    count += 1
    if pockets.isFull {
      pockets.shift()
    }
    pockets.push(Pocket { key: count, price: price, usd: usd, longs: longs, start: t, open: true })
    lines.get(count).set(t, price, t, price, width: 2.0, color: longs ? color.red : color.green, extend: extend.right, text: label(usd, longs))
    burstAlert.trigger(Burst { side: longs ? "longs" : "shorts", price: price, usd: usd })
  }
}
```

## How it works

### Liquidations per bar

`data.stat` carries the market's liquidations: `sellLiq` is the value of liquidation orders that sold in the bar (long positions being closed), `buyLiq` the value that bought (shorts). On a chart they come from the liquidations the chart has seen; spot markets have none. The script treats a missing value as zero liquidations, which is what it means here.

### What counts as a burst

A fixed USD threshold would fire all day on BTC and never on a small coin. The script compares each side with its own average over `avgLen` bars and also asks for a minimum size, so a quiet market does not turn a few thousand dollars into a signal. `ta.sma` is called on every bar, before any early `return`, so its history has no holes.

### Levels as records

Each level is a `Pocket` record in a `Pocket[]` with room for 40. When it is full, `shift()` drops the oldest before the new one is pushed. Iterating an array gives copies, so a level that price has reached is written back by index with `open: false`. Its line keeps its pool key and is redrawn ending at the touching bar, dotted, so the chart shows where the level held until.

### Why the bar's extreme

Long liquidations fire as price falls through the longs' liquidation prices, so the cascade's end is near the bar's low; for shorts it is near the high. With the bar's data alone that is the best estimate of where the forced orders hit. Use a shorter chart interval to place the level more precisely.

## Measured liquidation maps

The levels here come from liquidations that already happened. For where open positions *would* be liquidated, use the chart's liquidation heatmap: on Hyperliquid it is measured from public positions, on other venues it is modelled from open-interest changes. Scripts do not read those maps; combine the two by eye.

## Adapting it

- **Only the bigger side.** Drop `shortBurst` to track long flushes alone, the classic capitulation bottom.
- **Weight by size.** Pass `width: math.clamp(usd / minUsd, 1.0, 4.0)` so larger bursts draw thicker lines.
- **Expire old levels.** Close levels older than a day: compare `t - p.start` with `time.days(1)`.

## Related

- [Funding extremes](/docs/scripting/examples/funding-extremes) for positioning stress from funding.
- [User-defined types](/docs/scripting/guides/user-defined-types#copying) explains writing records back by index.
- [`data` reference](/docs/scripting/reference/catalog/data), [`entities` reference](/docs/scripting/reference/catalog/entities)
