# Wall absorption

> Trade a wall of the order book that market orders hit without getting through, with the stop behind it; mark walls that were pulled or filled.

The liquidity heatmap shows real resting orders. A large one, a wall, is a price where
someone is ready to trade size. When aggressive market orders run into it and it
stays, refilled or simply big enough, the move runs out at that price: the wall
absorbed it. That is a trade with a clear reason and a clear invalidation. Go with
the wall; if price gets through it, the reason is gone, so the stop sits just behind
it.

Walls can also be pulled before price arrives (spoofing) or eaten through (a real
breakout). The strategy tells those apart by comparing the wall's size before and after
the bar with the volume that traded at its prices, and marks them on the chart; neither
is traded.

It ships with Flowscope as a community script. It reads the book from `data.book` (the
heatmap's cells) and the volume at each price from `data.volume` (the footprints).

## The script

```flowscope title="wall-absorption.fs"
script "Wall Absorption"

// Trades a large resting order that takes the hit and holds: aggressive
// market orders run into a wall of the order book, the wall stays (or
// refills) and price fails to get through. The trade goes with the wall,
// the stop sits behind it.
//
// Long at a bid wall (shorts mirror it at an ask wall):
//   1. Wall: on the bar before, the nearest heatmap cell of the book below
//      the price within the search range that holds at least the set
//      multiple of the average cell there and the smallest notional.
//   2. Hit: this bar trades into the wall's cell, and the market sells
//      traded at the wall's prices (the footprint) reach the set share of
//      what rested there.
//   3. Held: the wall still holds at least the set share of its size after
//      the bar (absorbed, maybe refilled), and the bar closes not below the
//      wall, away from its low by the set share of its range.
//   Entry at the next open; stop behind the wall by a buffer of ATR;
//   target a multiple of the risk or the largest wall on the other side.
//
// What a wall did is told apart by the size it lost and the volume traded
// at its price: lost with little traded there, it was pulled (a fake, often
// spoofing); lost to trades, it was filled (a real breakout, no trade
// against it). Both are marked when price was at the wall.
//
// The book of a bar is the heatmap's cell value: the mean resting size at
// the bar's minute closes. Everything is read from the bars completed;
// orders fill at the next bar's open.

input (
  input.tab(title: "Settings")
  input.section(title: "Market")
  marketSel = input.select("Chart market", title: "Market", options: ["Chart market", "Binance + Bybit + OKX", "Binance", "Bybit", "OKX", "Binance spot + Coinbase"], description: "Whose order book and footprint are read. Chart market (default): the chart's own. The other choices sum the books (and the volume traded at each price) of those venues for the chart's coin.")
  input.section(title: "Wall")
  searchPct = input.float(0.5, title: "Search range (%)", min: 0.05, max: 5.0, description: "How far from the mid a wall may rest, in percent of the price.")
  wallMult = input.float(4.0, title: "Size (x average cell)", min: 1.0, max: 100.0, description: "The wall's cell must hold at least this multiple of the average cell in the search range.")
  minWallUsd = input.float(500000.0, title: "Smallest wall (USD)", min: 0.0, description: "And at least this much resting, in the quote currency. 0 turns the floor off.")
  input.section(title: "Absorption")
  hitPct = input.float(20.0, title: "Traded into the wall (% of its size)", min: 0.0, max: 1000.0, description: "Market orders traded at the wall's prices in the bar (sells at a bid wall, buys at an ask wall), against the size that rested there before.")
  holdPct = input.float(60.0, title: "Wall left after the bar (%)", min: 0.0, max: 500.0, description: "The wall must still hold at least this share of its size: it absorbed the orders instead of being eaten or pulled. Above 100 % it refilled.")
  rejectPct = input.float(30.0, title: "Close away from the wall (% of the range)", min: 0.0, max: 100.0, description: "The bar must close at least this far from its extreme on the wall's side, and not beyond the wall.")
  goneShare = input.float(50.0, title: "Pulled or filled (% lost)", min: 1.0, max: 100.0, description: "A wall that lost at least this share is marked pulled (little traded there) or filled (traded away).")
  input.section(title: "Exits")
  stopBuffer = input.float(0.3, title: "Stop behind the wall (ATR)", min: 0.0, max: 10.0, description: "The stop sits this far beyond the far edge of the wall's cell.")
  targetMode = input.select("R multiple", title: "Target", options: ["R multiple", "Opposite wall"], description: "R multiple: the risk times the multiple. Opposite wall: the largest wall on the other side within the target range, else the R multiple; never less than 1 R.")
  targetR = input.float(2.0, title: "Target (R)", min: 0.2, max: 20.0)
  targetPct = input.float(1.0, title: "Opposite wall range (%)", min: 0.05, max: 10.0, sameLine: true)
  maxBars = input.int(45, title: "Time stop (bars)", min: 1, max: 5000, description: "A trade still open after this many bars is closed at market.")
  input.section(title: "Risk")
  riskPct = input.float(0.5, title: "Risk per trade (% equity)", min: 0.05, max: 10.0, description: "The size is set so that the stop loses this share of the equity.")
  maxExposure = input.float(3.0, title: "Largest position (x equity)", min: 0.1, max: 50.0, description: "A tight stop never makes the position larger than this multiple of the equity.")
  feePct = input.float(0.05, title: "Fee per side (%)", min: 0.0, max: 1.0, description: "Taker fee of each fill, for the Strategy Tester.")
  allowLong = input.bool(true, title: "Longs")
  allowShort = input.bool(true, title: "Shorts", sameLine: true)
  showWalls = input.bool(true, title: "Show walls, events, stops and targets")
  longColor = input.color(#00e676, title: "Long")
  shortColor = input.color(#ff1744, title: "Short", sameLine: true)
  input.tab(title: "About")
  input.section(title: "The idea", description: "The liquidity heatmap shows real resting orders. A wall that market orders hit without getting through tells that someone is absorbing them: the move runs out at that price and often turns. The trade follows the wall, with the stop just behind it: if the wall goes, the reason for the trade goes with it.")
  input.section(title: "Fakes and breakouts", description: "Walls can be pulled before price arrives (spoofing) or eaten through (a real breakout). The script compares the wall's size before and after the bar with the volume that traded at its prices: a wall that shrank with little traded was pulled, one that shrank as much as traded was filled. Neither gives a trade; both are marked.")
  input.section(title: "Reading the chart", description: "The dotted lines mark the nearest walls below and above. A triangle marks an absorption, a label the entry with what traded into the wall and what was left. A cross marks a pulled wall, a square a filled one. Entry, stop and target lines run while the trade is open.")
  input.section(title: "Data", description: "data.book gives each bar's book as the heatmap's cells show it (the mean resting size at the bar's minute closes, summed in the heatmap's price bin); data.volume gives the volume traded at each price from the footprints. Book history reaches 2,000 bars back.")
  input.section(title: "Limits", description: "The book is sampled once a minute: a wall placed and pulled within a minute is not seen, and on bars of several minutes a wall that stood only part of the bar counts by that share. Backtests over short histories are anecdotes, not evidence.")
)

setup venue = match marketSel {
  "Binance + Bybit + OKX" => markets.agg("binancef", "bybitf", "okxf")
  "Binance" => "binancef"
  "Bybit" => "bybitf"
  "OKX" => "okxf"
  "Binance spot + Coinbase" => markets.agg("binance", "coinbase")
  _ => context.exchange
}

strategy(
  initialCapital: 10000.0,
  commissionType: strategy.commission.percent,
  commissionValue: feePct,
  slippage: 2,
  leverage: 10.0,
  fundingCosts: true
)

data (
  chart = subscribe(data.ohlcv)
  book = subscribe(data.book, exchange: venue)
  vol = subscribe(data.volume, exchange: venue)
)

plot (
  bidWall = plot.line(title: "Bid wall", color: #00e67690, style: linestyle.dotted, showValue: false)
  askWall = plot.line(title: "Ask wall", color: #ff174490, style: linestyle.dotted, showValue: false)
)

type Absorbed {
  side: string
  wall: float
  rested: float
  traded: float
  left: float
}

state (
  tradeDir = 0
  entryPrice = 0.0
  stopPrice = 0.0
  targetPrice = 0.0
  tradeFrom: time? = null
  nextId = 0
  marks = entities.markerPool(max: 600)
  labels = entities.labelPool(max: 400)
  lines = entities.linePool(max: 1200)
)

alert longAlert = alert(title: "Wall absorbed, long", onClose: true, payload: Absorbed)
alert shortAlert = alert(title: "Wall absorbed, short", onClose: true, payload: Absorbed)

// The smallest wall (quote value) within `pct` of the mid `ago` bars back:
// the floor, and the multiple of the average cell there.
fn wallFloor(pct: float, ago: int) -> float {
  let mid = book.mid(barsAgo: ago) ?? 0.0
  let cell = book.cellSize(barsAgo: ago) ?? 0.0
  if mid <= 0.0 || cell <= 0.0 {
    return minWallUsd
  }
  let total = book.liquidity(mid * (1.0 - pct / 100.0), mid * (1.0 + pct / 100.0), unit: bookunit.quote, barsAgo: ago) ?? 0.0
  let cells = math.max(1.0, 2.0 * mid * pct / 100.0 / cell)
  return math.max(minWallUsd, wallMult * total / cells)
}

// Sold (bought) by market orders at prices from `lo` to `hi` in this bar,
// in base units.
fn tradedAt(lo: float, hi: float) -> float {
  let s = vol.summary(from: lo, to: hi)
  return s == null ? 0.0 : s.sell
}
fn boughtAt(lo: float, hi: float) -> float {
  let s = vol.summary(from: lo, to: hi)
  return s == null ? 0.0 : s.buy
}

// Draws the open trade's lines up to `t`.
fn drawTrade(t: time) {
  if !showWalls || tradeFrom == null {
    return
  }
  let start = tradeFrom ?? t
  lines.get(nextId * 3).set(start, entryPrice, t, entryPrice, width: 1.0, color: #ffffffb0, style: linestyle.dashed, text: str.format("Entry {0:.2}", entryPrice))
  lines.get(nextId * 3 + 1).set(start, stopPrice, t, stopPrice, width: 1.0, color: #ff5252, text: str.format("SL {0:.2}", stopPrice))
  lines.get(nextId * 3 + 2).set(start, targetPrice, t, targetPrice, width: 1.0, color: #26a69a, text: str.format("TP {0:.2}", targetPrice))
}

on chart.close {
  let o = chart.open
  let h = chart.high
  let l = chart.low
  let c = chart.close
  let t = chart.time
  let atr = chart.atr(14)
  if o == null || h == null || l == null || c == null || t == null || atr == null || atr <= 0.0 {
    return
  }
  let range = h - l

  // The walls of the book just completed, for the chart.
  let floorNow = wallFloor(searchPct, 0)
  let below = book.wallBelow(searchPct, minSize: floorNow, nearest: true, unit: bookunit.quote)
  let above = book.wallAbove(searchPct, minSize: floorNow, nearest: true, unit: bookunit.quote)
  if showWalls {
    bidWall.plot(below == null ? null : below.price + below.binSize / 2.0)
    askWall.plot(above == null ? null : above.price + above.binSize / 2.0)
  }

  let flat = strategy.positionSize == 0.0
  if tradeDir != 0 {
    drawTrade(t)
    if flat {
      tradeDir = 0
    }
  }
  let open = strategy.openTrade(0)
  if open != null && chart.index - open.entryBarIndex >= maxBars {
    strategy.closeAll(comment: "Time stop")
  }

  // The walls that stood before this bar, and what the bar did to them.
  let floorBefore = wallFloor(searchPct, 1)
  let bidW = book.wallBelow(searchPct, minSize: floorBefore, nearest: true, unit: bookunit.quote, barsAgo: 1)
  let askW = book.wallAbove(searchPct, minSize: floorBefore, nearest: true, unit: bookunit.quote, barsAgo: 1)
  let gone = goneShare / 100.0
  let signal = 0
  let rested = 0.0
  let traded = 0.0
  let left = 0.0
  let wallPrice = 0.0
  let wallEdge = 0.0

  if bidW != null && l <= bidW.top {
    // Rows of the footprint inside the wall's cell (not the next cell's;
    // the edges a hair low against rounding).
    let lo = bidW.price - bidW.binSize * 0.001
    let hi = bidW.top - bidW.binSize * 0.001
    let ch = book.change(bidW.price + bidW.binSize / 2.0)
    let sold = tradedAt(lo, hi)
    if ch != null && ch.before > 0.0 {
      let held = ch.after >= ch.before * holdPct / 100.0
      let lost = ch.before - ch.after
      if allowLong && held && sold >= ch.before * hitPct / 100.0 && c >= bidW.price && c - l >= range * rejectPct / 100.0 {
        signal = 1
        rested = ch.before
        traded = sold
        left = ch.after
        wallPrice = bidW.price + bidW.binSize / 2.0
        wallEdge = bidW.price
      } else if showWalls && lost >= ch.before * gone {
        // Shrank: eaten by the sells there (filled) or pulled.
        let filled = sold + boughtAt(lo, hi) >= lost * 0.5
        marks.get(str.format("b{0}", chart.index)).set(t, bidW.price, shape: filled ? shape.square : shape.cross, color: filled ? #ffd740 : #b0bec5, size: 6.0)
      }
    }
  }
  if signal == 0 && askW != null && h >= askW.price {
    let lo = askW.price - askW.binSize * 0.001
    let hi = askW.top - askW.binSize * 0.001
    let ch = book.change(askW.price + askW.binSize / 2.0)
    let bought = boughtAt(lo, hi)
    if ch != null && ch.before > 0.0 {
      let held = ch.after >= ch.before * holdPct / 100.0
      let lost = ch.before - ch.after
      if allowShort && held && bought >= ch.before * hitPct / 100.0 && c <= askW.top && h - c >= range * rejectPct / 100.0 {
        signal = -1
        rested = ch.before
        traded = bought
        left = ch.after
        wallPrice = askW.price + askW.binSize / 2.0
        wallEdge = askW.top
      } else if showWalls && lost >= ch.before * gone {
        let filled = bought + tradedAt(lo, hi) >= lost * 0.5
        marks.get(str.format("a{0}", chart.index)).set(t, askW.top, shape: filled ? shape.square : shape.cross, color: filled ? #ffd740 : #b0bec5, size: 6.0)
      }
    }
  }

  if !flat || signal == 0 {
    return
  }
  // The trade: the stop behind the wall, sized by the risk to it.
  let sign = signal > 0 ? 1.0 : -1.0
  let stop = wallEdge - sign * stopBuffer * atr
  let risk = (c - stop) * sign
  if risk <= 0.0 {
    return
  }
  let rTarget = c + sign * targetR * risk
  let target = rTarget
  if targetMode == "Opposite wall" {
    let floorT = wallFloor(targetPct, 0)
    let other = signal > 0 ? book.wallAbove(targetPct, minSize: floorT, unit: bookunit.quote) : book.wallBelow(targetPct, minSize: floorT, unit: bookunit.quote)
    if other != null {
      // Just before the other wall, at least one R away.
      let before = signal > 0 ? other.price : other.top
      target = (before - c) * sign >= risk ? before : rTarget
    }
  }
  let qty = math.min(strategy.equity * riskPct / 100.0 / risk, strategy.equity * maxExposure / c)
  if qty <= 0.0 {
    return
  }
  let name = signal > 0 ? "Absorbed long" : "Absorbed short"
  strategy.entry(name, signal > 0 ? strategy.long : strategy.short, qty: qty, comment: str.format("wall {0:.2} held {1:.0}%", wallPrice, left / rested * 100.0))
  strategy.exit(name + " exit", name, stop: stop, limit: target, comment: "SL / TP")
  nextId += 1
  tradeDir = signal
  entryPrice = c
  stopPrice = stop
  targetPrice = target
  tradeFrom = t
  drawTrade(t)
  if showWalls {
    marks.get(str.format("s{0}", chart.index)).set(t, signal > 0 ? l - 0.2 * atr : h + 0.2 * atr, shape: signal > 0 ? shape.up : shape.down, color: signal > 0 ? longColor : shortColor, size: 8.0)
    let text = str.format("{0} · wall {1:.2}\nhit {2:.2} of {3:.2}\nleft {4:.0}%", signal > 0 ? "Long" : "Short", wallPrice, traded, rested, left / rested * 100.0)
    labels.get(nextId).set(t, signal > 0 ? l - 0.7 * atr : h + 0.7 * atr, text, color: signal > 0 ? longColor : shortColor)
  }
  let payload = Absorbed { side: signal > 0 ? "long" : "short", wall: wallPrice, rested: rested, traded: traded, left: left }
  if signal > 0 {
    longAlert.trigger(payload)
  } else {
    shortAlert.trigger(payload)
  }
}

on chart.update {
  let status = strategy.positionSize > 0.0 ? " · long" : strategy.positionSize < 0.0 ? " · short" : ""
  script.setTitle(str.format("Wall Absorption{0}", status))
}
```

Open a liquid market on 1m to 5m, with the Heatmap if you want to see the walls, and add
the script. The Strategy Tester opens with the backtest.

## How it works

### The wall

`book.wallBelow(searchPct, minSize: …, nearest: true, unit: bookunit.quote, barsAgo: 1)`
finds the nearest heatmap cell below the mid, on the bar before, that holds at least
`wallMult` times the average cell within the search range and at least `minWallUsd`.
`wallFloor` computes the threshold from `book.liquidity` and `book.cellSize`. A cell is
what the heatmap draws for that bar and price: the mean resting size at the bar's minute
closes, summed in the heatmap's bin.

### Hit and held

| Check | Read | Default |
|---|---|---|
| Price reached the wall | the bar's low against the cell's top | — |
| Market orders hit it | sells traded at the cell's prices: `vol.summary(from:, to:).sell` | ≥ 20 % of the wall |
| It held | `book.change(price).after` against `.before` | ≥ 60 % left |
| Price failed to break | the close not below the wall, `rejectPct` of the range off the low | 30 % |

Shorts mirror it at an ask wall with the buys traded there.

### Pulled or filled

A wall that lost at least `goneShare` of its size while price was at it is marked: a
cross when little traded at its prices (pulled, maybe spoofed), a square when the
volume there accounts for at least half of what it lost (filled, a real breakout).

### Exits and size

| Exit | Rule |
|---|---|
| Stop | Behind the wall's cell by `stopBuffer` ATR |
| Target | `targetR` times the risk, or the largest wall on the other side within `targetPct` (at least 1 R) |
| Time stop | `strategy.closeAll` after `maxBars` bars |

The size risks `riskPct` of the equity, capped at `maxExposure` times the equity;
commission 0.05 % a side, slippage 2 ticks, funding charged.

### What the minute book cannot see

The book is recorded once a minute. A wall placed and pulled inside a minute never shows,
and on a 5m bar a wall that stood two minutes counts two fifths of its size. Absorption
shows best on 1m bars.

## What we measured

On the BTCUSDT Binance perpetual with the data hub's history from Thursday 15 October
14:52 to Friday 16 October 19:15 UTC (about 28 hours), at the defaults: no trade on 1m or
5m. Qualifying walls (mostly $12M to $30M at round prices) were touched 23 times on 1m;
the ones hit hard were filled, the others barely traded or were pulled. With looser
thresholds (hit 10 %, held 40 %, rejection 20 %, search 1 %, size 3×) there were two trades
on 1m (+$68.62 and −$63.72, net +$4.89 on $10,000) and two losers on 5m (−$110.11). A day
of data cannot tell whether the setup has an edge: treat these as a check that the
mechanics work, and test on a longer history.

## Adapting it

- **Several books.** `Market: Binance + Bybit + OKX` sums the books and the footprints of
  the three venues; spot books (`Binance spot + Coinbase`) hold the unleveraged walls.
- **Only refills.** Set `holdPct` above 100 to trade walls that grew while hit.
- **Liquidation context.** Require a liquidation cluster beyond the wall with
  `data.liqmap`: a wall in front of a magnet holds less often.

## Related

- [Order book and liquidation maps](/docs/scripting/guides/heatmap-data)
- [Liquidation sweep reversal](/docs/scripting/examples/liquidation-sweep-reversal)
- [Strategies & backtesting](/docs/scripting/strategies)
