Backtesting Absorption

Does absorption signal reversals?

Let's quantify absorption - an increased level of volume at a particular price level relative to other price levels.

See also What is Absorption.

First let's create an indicator to make sure we are adequately targeting areas of absorption. If we look at 1m footprint candles we can see an average of 5 contracts traded per price level. High volume can be considered anything above 20 contracts per price level. (We can come to this conclusion either quantitatively or qualitatively.) We will highlight each buy or sell price with a yellow box.

Absorption boxes indicator
const ABSORPTION_SIZE = 20
const NEARBY_TICKS = 5
const NEARBY_MULTIPLE = 3
const NEARBY_TIME_MS = 60_000

function median(values: number[]): number {
  if (values.length === 0) return 0
  const sorted = [...values].sort((a, b) => a - b)
  const mid = Math.floor(sorted.length / 2)
  return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
}

interface RowEntry { candleTime: number; price: number; buys: number; sells: number }

function buildAllEntries(): RowEntry[] {
  const entries: RowEntry[] = []
  for (const c of candles) {
    for (const level of c.levels) {
      entries.push({ candleTime: c.time, price: level.price, buys: level.buys, sells: level.sells })
    }
  }
  return entries
}

chart.frame((view, draw) => {
  const allEntries = buildAllEntries()
  const globalMedianTotalVolume = median(allEntries.map(r => r.buys + r.sells))
  const nearbyThreshold = globalMedianTotalVolume * NEARBY_MULTIPLE

  let sellBoxCount = 0
  let buyBoxCount = 0

  for (const c of view.candles) {
    const half = c.interval / 2
    const rowEntries = Object.entries(c.rows).map(([price, level]) => ({
      price: Number(price),
      buys: level.buys,
      sells: level.sells,
    }))
    const tickRange = NEARBY_TICKS * c.increment

    const nearbyEntries = (p: number) =>
      allEntries.filter(
        r => !(r.candleTime === c.time && r.price === p) &&
          Math.abs(r.candleTime - c.time) <= NEARBY_TIME_MS &&
          Math.abs(r.price - p) <= tickRange
      )

    const suppressSellAt = new Set<number>()
    const suppressBuyAt = new Set<number>()

    for (const { price: p, sells } of rowEntries) {
      if (sells <= ABSORPTION_SIZE) continue
      for (const r of nearbyEntries(p)) {
        if (r.buys > nearbyThreshold) {
          suppressSellAt.add(p)
          if (r.candleTime === c.time) suppressBuyAt.add(r.price)
        }
      }
    }
    for (const { price: p, buys } of rowEntries) {
      if (buys <= ABSORPTION_SIZE) continue
      for (const r of nearbyEntries(p)) {
        if (r.sells > nearbyThreshold) {
          suppressBuyAt.add(p)
          if (r.candleTime === c.time) suppressSellAt.add(r.price)
        }
      }
    }

    for (const { price: p, buys, sells } of rowEntries) {
      const top = p + c.increment / 2
      const bottom = p - c.increment / 2

      if (sells > ABSORPTION_SIZE && !suppressSellAt.has(p)) {
        sellBoxCount++
        draw.rectangle({
          price1: bottom, price2: top,
          time1: c.time, time2: c.time + half,
          color: 'transparent', borderColor: '#ffe600', borderWidth: 2,
        })
      }
      if (buys > ABSORPTION_SIZE && !suppressBuyAt.has(p)) {
        buyBoxCount++
        draw.rectangle({
          price1: bottom, price2: top,
          time1: c.time + half, time2: c.time + c.interval,
          color: 'transparent', borderColor: '#ffe600', borderWidth: 2,
        })
      }
    }
  }

  const last = view.candles[view.candles.length - 1]
  if (last) {
    draw.text({
      time: view.endTime,
      price: view.maxPrice,
      text: `Absorption boxes (visible): ${sellBoxCount + buyBoxCount} (sell ${sellBoxCount} / buy ${buyBoxCount})`,
      color: '#ffe600',
      size: 12,
      align: 'right',
    })
  }
})

If you look at the code, notice we additionally have a filter. We want to remove areas of high volume on both sides: if we have lots of buying and lots of selling, we wouldn't call this absorption. Let's see how it looks plotted across a whole day with 1m bars.

Full day of 1 minute footprint candles with the absorption indicator boxing every flagged price level.
Footprint candle with one price level boxed in yellow as absorption.
Candles are shaded by volume, brighter red for heavy selling and brighter blue for heavy buying. The boxed level carries far more volume than the levels around it, which is the absorption we want to highlight.
High volume footprint candles with heavy buying and selling at the same prices, left unboxed by the absorption indicator.
Heavy buying and heavy selling at the same prices. The filter leaves these unboxed as high volume rather than absorption.

While not that technical of an indicator, I'm happy with what is and isn't highlighted. Let's now look at the resulting price movement in ticks (+1 = 1 tick up, -1 = 1 tick down) in several time windows.

Buy-side events (22)

Buy-side events (22)
timepricevolume10s30s1m5m
13:30444921+20+27+28+19
13:314451.7026+3+4+4-21
13:314452300+1+1-24
13:324452.2023+1-2-3-38
13:424448.5039-8-10-10+36
13:424448.7022-10-12-12+34
13:45445047+13+9+17-12
13:454450.5022+8+4+12-17
13:484450.7023-6-14-20-18
14:004447.5029+42+38+44+40
14:014452220+12+2+4
14:074453.60330-5-5-10
14:254453.4021-3-6-4-15
14:274453250-2-1-15
14:43445527+22+13+25+79
14:47446041+15+20+29+14
14:494460.3023-6-7-14+22
14:504459.7023-12-13+1+21
14:524460.3025+3+17+35+18
14:54446529-22-21-32-33
15:154460.50220-3-5-10
16:504460.2025-2-1-4-9
average27.2+2.6+2.2+4.0+3.0

Sell-side events (21)

Sell-side events (21)
timepricevolume10s30s1m5m
13:364448.7027+3-3-3-7
13:404447.1024+5+13+9+33
13:404447.60340+8+4+28
13:574447.50240-11-9+47
13:584446.4026+4+10+12+61
13:584446.5026+3+9+11+60
13:584446.6027+2+8+10+59
13:584446.7030+1+7+9+58
13:58444735-2+4+6+55
14:09445323-1-4-6-6
14:244450.5023+20+29+30+19
14:354450.30240+7+11+9
14:41445122+1+5+25+76
14:434454.1022+31+22+34+88
14:484463.9021-22-44-46-1
15:05445722-11-11-14+9
15:154459.4023+11+8+6+1
15:264456.9024+9+4+11+10
16:14446221-4-8-8-29
16:344458.2024+5+4+9-1
17:30445721-100-10
average24.9+2.6+2.7+4.8+26.6

We expected to see an average of positive ticks after sell absorption but not positive ticks after buy absorption, so these are unconvincing results.

An issue with our averaging is that many of these absorption events occur within the same time period. What if we remove those overlaps from the results?

Averages once overlapping events are grouped
sideeventsvolume10s30s1m5m
buy1828.1+3.1+3.1+4.8+5.9
sell1624.8+2.5+1.8+4.3+18.7

The results for our buy absorption are even worse. At the surface level, I think we can safely assume trading directly with absorption is a bad strategy. However I wouldn't count this market behavior out, it would be prudent to look at these events individually and devise a stricter indicator.

Replay these absorption events yourself by selecting a time in the tables.

Start practicing

See plans and pricing