LordOfCactus

Terminalde aç
Paylaşmak, beğenmek ve takip etmek için Temel üyeliğe kaydolabilirsiniz.Giriş yapın ya da üye olun

LordOfCactus

@anileren
7 gönderi2 takipçi2 takip edilen
Will it fall now?
#BTC
BTCUSDT · 1m · 20 Eki 202620 Eki
2 çizim · DEMA, Liquidity ClustersGrafikte aç
Kodu görüntüle
Liquidity Clusters
// © Markuda
// Groups nearby swing highs and lows into stop levels and counts how breaks of them end.

// ═══════════════════════════════════════════════════════════════════════════════
// LIQUIDITY CLUSTERS
//
// Where are stops likely to sit on this symbol, how much has traded there, and
// when price breaks a level, does the break usually fail or hold?
//
// Stops tend to rest just beyond highs and lows that held more than once. The
// indicator groups swing highs (and swing lows) that sit close to each other
// into one level. Resistance (above price) is red, support (below price) is
// green. Every swing point that formed a level is marked with a dot, so you can
// see why the level is there.
//
// Each level is drawn as a zone the height of the wick that set it: from the
// level to the body of that swing bar, where price traded and turned away (at
// least 0.15 and at most 0.6 of an average bar range). A bright line marks the
// level itself. Live levels stand out, history steps back. The two nearest live
// levels on each side get a denser zone, a 2-pixel line and a volume histogram
// at the zone's right end: the zone is cut into six price slices, each as long
// as the volume that traded in it (the busiest one is 16 bars long on the
// nearest level, 11 on the next, and solid in the level's bright color; the
// others are at least a third opaque). Other live levels keep a faint zone and a
// thin line. When price comes close to a level, its zone brightens and its label
// turns solid. Zones stop just after the last bar ("Extend levels to the right"
// runs them to the price axis).
//
// The two nearest levels on each side get a label just after the last bar:
//   Resistance / tested twice / 57% selling
//   a resistance level formed by two swing highs; of the volume traded at
//   this level, an estimated 57% was selling
//   Support / tested 3 times / 61% buying
//   a support level tested three times, where an estimated 61% of the volume
//   was buying
// Move the cursor over a label (it also gives the level's price), an arrow or a
// dot for details.
//
// When price trades beyond a level, the level is broken: its line and histogram
// go, and the zone stays as a faint fill up to the break. From that bar on the
// indicator waits a few bars for the outcome:
//   Failed break     price closed back inside the level
//   Confirmed break  price closed beyond the level several bars in a row
//   Undecided        the waiting time ran out without either
// While it waits, the level's label counts down the bars left. The outcome is
// marked with a small arrow where it was decided, pointing the way price went
// (amber = failed break, blue = confirmed break; move the cursor over it for
// details).
// It is marked then, never earlier, so nothing on the chart is redrawn later.
// Broken zones fade as newer breaks follow. The latest 6 stay on the chart ("Broken
// zones kept"); older ones still count in the table.
//
// The table counts these outcomes on this symbol and timeframe, split by level
// strength and by the volume of the breaking bar. It answers one question: on
// this chart, do breaks of strong levels usually fail, or hold?
// By default (Auto) it sits top right and moves top left only when it would
// cover a labeled resistance or the price there.
//
// Terms used on the chart and in the table
//   Level      one or more swing highs (or lows) within a small distance of each
//              other. Its price is the most extreme point of the group.
//   Band       the distance within which swing points join the level. Volume
//              inside it gives the buy / sell share on the label.
//   Zone       what is drawn for a level: from the level to the body of the
//              swing bar that set it. The histogram counts the volume traded
//              inside it, slice by slice.
//   ATR        the average bar range over the last 21 bars; every distance
//              setting is measured in it, so the same value fits any price.
//   Tested     how many swing points formed the level.
//   Strength   weak / moderate / strong, from tests and the volume on those bars.
//   Buy / sell volume  for each bar that traded inside the band, its volume is
//              split by where the bar closed in its own range (a close at the
//              high counts all of it as buying). It is an estimate from price,
//              not order data. Resistance shows the sell share, support the
//              buy share.
//   Break volume  the volume of the bar that broke the level, against the
//              50-bar average: high = 1.5× the average or more, low = less.
//
// Default values were chosen by measurement on BTCUSDT and ETHUSDT, 1h and 4h,
// 5,000 bars each (October 2026). Breaks decided per 5,000 bars:
//   swing size 5 → 49-60 · 6 → 38-53 · 8 → 27-40 · 13 → 17-20. Six keeps enough
//     breaks for the table without drawing every small wiggle.
//   level width 0.15 → 23-32 · 0.3 → 38-53 · 0.5 → 51-61. Above 0.3 separate
//     levels start to run together into one line.
//   tests needed 1: every swing point is a level, and a level that gathers
//     more points gets stronger. With 2, only 5-18 breaks per 1,000 bars were
//     decided and the table rarely had enough for a percentage; with 1, 65-82.
//   levels per side 5: 1-7 live levels per side at the last bar across the four sets.
//   bars to confirm 10: no break was left undecided in the four sets, so the
//     wait never cuts the table short.
//   break volume 1.5× average: at 1×, 95% of breaks counted as high-volume and
//     the split meant nothing; at 1.5× both groups get samples.
//   lookback 1000: enough decided breaks for a percentage on 1h and 4h, and the
//     run time stays the same however long the chart is.
//
// This is an indicator: it works only from past prices and does not know the future.
// Nothing it draws is a forecast or investment advice; it does not trade and
// places no orders.
// ═══════════════════════════════════════════════════════════════════════════════

indicator("Liquidity Clusters", "Clusters", overlay = true, max_lines_count = 500, max_labels_count = 500, max_boxes_count = 500)

// ── Inputs: what is shown, then sensitivity, then style ──
var showNotes = inputBool(true, "Level labels", group = "On the chart", tooltip = "Labels the two nearest levels above and below price: support or resistance, how many times price tested it, and the estimated buy or sell share of the volume traded there.")
var keepSwept = inputBool(true, "Keep broken levels", group = "On the chart", tooltip = "When off, broken levels disappear from the chart but still count in the table.")
var keptBroken = inputInt(6, "Broken zones kept", min = 1, max = 50, step = 1, group = "On the chart", tooltip = "How many of the latest broken zones stay on the chart, each with its outcome arrow. Older ones leave the chart but still count in the table.")
var markOutcomes = inputBool(true, "Mark break outcomes", group = "On the chart", tooltip = "A small arrow where a break failed or was confirmed, pointing the way price went. Move the cursor over it for details.")
var showProfile = inputBool(true, "Outcome table", group = "On the chart", tooltip = "Counts, for this symbol and timeframe, how often breaks of a level failed and how often they were confirmed.")
var swingSize = inputInt(6, "Swing size (bars each side)", min = 2, max = 50, step = 1, group = "Sensitivity", tooltip = "A high or low counts as a swing point when no bar within this many bars on either side goes beyond it. It is confirmed, and drawn, this many bars after it forms. Smaller values find more, smaller levels.")
var tolerance = inputFloat(0.3, "Level width (× ATR)", min = 0.05, max = 2.0, step = 0.05, group = "Sensitivity", tooltip = "Swing points closer than this join the same level. Measured in average bar ranges (ATR), so one value fits any price.")
var minTouches = inputInt(1, "Tests needed to draw a level", min = 1, max = 10, step = 1, group = "Sensitivity", tooltip = "A level is drawn and counted only after this many swing points have formed it.")
var perSide = inputInt(5, "Levels per side", min = 1, max = 25, step = 1, group = "Sensitivity", tooltip = "The most live levels kept above and below price. When full, the weakest and oldest one is dropped.")
var lookback = inputInt(1000, "Lookback (bars)", min = 300, max = 5000, step = 100, group = "Sensitivity", tooltip = "Levels are built from the most recent bars only. More bars mean more history in the table and a slower run.")
var decisionBars = inputInt(10, "Bars to confirm a break", min = 2, max = 100, step = 1, group = "Sensitivity", tooltip = "How many bars after a break the indicator waits to see whether the break fails or is confirmed.")
var acceptCloses = inputInt(2, "Closes beyond to confirm", min = 1, max = 20, step = 1, group = "Sensitivity", tooltip = "How many closes in a row beyond the level confirm the break.")
var minOutcomes = inputInt(5, "Min. breaks for a percentage", min = 1, max = 50, step = 1, group = "Sensitivity", tooltip = "A percentage is shown only after this many decided breaks. Until then the table shows a dash.")
var approachAtr = inputFloat(0.5, "Proximity (× ATR)", min = 0.1, max = 5.0, step = 0.1, group = "Sensitivity", tooltip = "When price comes this close to a live level, the level is highlighted and the proximity alert fires.")
var tableCorner = inputString("Auto", "Table position", group = "Style", tooltip = "Where the table sits. Auto keeps it top right and moves it top left only when, top right, it would cover a labeled resistance or the price.", options = ["Auto", "Top right", "Top left", "Bottom right", "Bottom left"])
var tableSize = inputString("Medium", "Table size", group = "Style", tooltip = "Text size of the table, and the size of the break outcome arrows.", options = ["Small", "Medium", "Large"])
var panelLook = inputString("Dark", "Table colors", group = "Style", tooltip = "Dark for a dark chart theme, Light for a light one. Light also makes the zones one step denser so they stand out on a light chart.", options = ["Dark", "Light"])
var extendRight = inputBool(false, "Extend levels to the right", group = "Style", tooltip = "When on, live levels and their zones run to the price axis. When off, they stop just after the last bar.")
var colAbove = inputColor("#fb5b6b", "Resistance", group = "Style", tooltip = "Color of resistance levels, their zones, histograms and labels.")
var colBelow = inputColor("#33d69f", "Support", group = "Style", tooltip = "Color of support levels, their zones, histograms and labels.")
var colReclaim = inputColor("#e3b341", "Failed break", group = "Style", tooltip = "Color of the failed break arrow and of the Failed column in the table.")
var colAccept = inputColor("#5d87c5", "Confirmed break", group = "Style", tooltip = "Color of the confirmed break arrow and of the Confirmed column in the table.")

// Table colors follow the chosen look (the chart theme cannot be read from code).
var lightPanel = panelLook == "Light"
var PAL_GROUND = lightPanel ? "#eef1f6" : "#161920"
var PAL_TEXT = lightPanel ? "#131722" : "#eceef2"
var PAL_MUTED = lightPanel ? "#6a6f7b" : "#818692"

// ── Types ──
// One group of swing points. side: 1 = highs (resistance), -1 = lows (support).
// phase: 0 forming or live, 1 broken and waiting, 2 decided.
// verdict: 1 failed break, 2 confirmed break, 3 undecided.
type Pocket {
  float lvl = na
  int side = 0
  int firstBar = 0
  int hits = 0
  float relVolSum = 0.0
  float wick = na
  float bandVol = 0.0
  float buyVol = 0.0
  int phase = 0
  int sweepBar = 0
  int streak = 0
  int verdict = 0
  int tier = 0
  bool loudSweep = false
  bool warned = false
  array ptBar = na
  array ptLvl = na
  array dots = na
  array sv = na
  array prof = na
  array layers = na
  line ln = na
  box band = na
  label note = na
  label tag = na
}

// ── Helpers ──
fn sizeOf(choice) => choice == "Small" ? size.tiny : choice == "Large" ? size.normal : size.small
fn cornerOf(choice) => choice == "Top left" ? position.top_left : choice == "Bottom right" ? position.bottom_right : choice == "Bottom left" ? position.bottom_left : position.top_right
fn tierOf(hits, relVolSum) {
  avgRel = hits > 0 ? relVolSum / hits : 0.0
  t = 1
  if hits >= 4 or (hits >= 3 and avgRel >= 1.2) {
    t := 3
  } else if hits >= 3 or (hits >= 2 and avgRel >= 1.2) {
    t := 2
  }
  return t
}
fn tierName(t) => t == 3 ? "Strong" : t == 2 ? "Moderate" : "Weak"
fn tierWord(t) => t == 3 ? "strong" : t == 2 ? "moderate" : "weak"
fn timesWord(n) => n == 1 ? "once" : n == 2 ? "twice" : toString(n) + " times"
fn tierWidth(t) => t == 3 ? 4 : t == 2 ? 2 : 1
fn tierFade(t) => t == 3 ? 0 : t == 2 ? 15 : 45
fn sideName(side) => side == 1 ? "resistance" : "support"
// Half-up rounding written out: both chart engines give the same digit on a .5 tie.
fn halfUp(x) => floor(x + 0.5)
fn pct(part, whole) => whole > 0 ? toString(halfUp(part * 100 / whole)) + "%" : "-"
fn oneDecimal(x) {
  v = halfUp(x * 10)
  return toString(floor(v / 10)) + "." + toString(v % 10)
}
fn sideColor(side) => side == 1 ? colAbove : colBelow
fn twoDigits(x) => x < 10 ? "0" + toString(x) : toString(x)
fn monthName(m) => m == 1 ? "Jan" : m == 2 ? "Feb" : m == 3 ? "Mar" : m == 4 ? "Apr" : m == 5 ? "May" : m == 6 ? "Jun" : m == 7 ? "Jul" : m == 8 ? "Aug" : m == 9 ? "Sep" : m == 10 ? "Oct" : m == 11 ? "Nov" : "Dec"
// The current bar's time written the way people read it: 7 Oct 2026, 04:00 (UTC).
fn stamp() => toString(dayofmonth) + " " + monthName(month) + " " + toString(year) + ", " + twoDigits(hour) + ":" + twoDigits(minute)
// On a light chart, text and labels take a darker shade of their color so they stay readable.
fn ink(c) => lightPanel ? colorRgb(halfUp(colorR(c) * 0.62), halfUp(colorG(c) * 0.62), halfUp(colorB(c) * 0.62)) : c
// The bright line: on a dark chart the color mixed halfway to white, on a light one its darker shade.
fn edgeOf(c) => lightPanel ? ink(c) : colorRgb(halfUp(colorR(c) * 0.5 + 127.5), halfUp(colorG(c) * 0.5 + 127.5), halfUp(colorB(c) * 0.5 + 127.5))

// The outcome arrow points the way price went after the break: down for a failed
// break of resistance, up for a confirmed one; the other way round for support.
fn arrowOf(p) => (p.side == 1) == (p.verdict == 1) ? "▼" : "▲"

// ── Measures ──
// Work happens only in the last `lookback` bars (plus a short warm-up for the
// averages). Before that a bar costs one comparison, so a 50,000-bar chart
// takes about as long as a 5,000-bar one.
var txtSize = sizeOf(tableSize)
var warmStart = lastBarIndex - lookback - 100
var windowStart = lastBarIndex - lookback
var float rangeAvg = na
var float volAvg = na
// The zone: from the level to the body of the swing bar that set it, the wick that
// price traded into and turned away from. At least 0.15 and at most 0.6 average
// bar range tall: a bar with almost no wick still shows a zone, a long one does not
// turn it into a block.
fn innerOf(p) => p.lvl - p.side * max(0.15 * rangeAvg, min(0.6 * rangeAvg, abs(p.lvl - p.wick)))

// ── State ──
var table board = na
var table boardBack = na
// The corner the table is in, decided on the last bar (Auto resolves to a top corner).
var tablePlace = ""
var pockets = arrayNew()
var pastSweeps = arrayNew()
// Table counters, index 0..2 = weak, moderate, strong.
var decided = arrayNewInt(3, 0)
var reclaims = arrayNewInt(3, 0)
var accepts = arrayNewInt(3, 0)
var loudDone = 0
var loudReclaim = 0
var loudAccept = 0
var quietDone = 0
var quietReclaim = 0
var quietAccept = 0

// Spreads a bar's volume over the zone's six price slices (slice 0 touches the
// level) by how much of the bar's range lies in each one; a bar that does not reach
// the zone adds nothing. The slices follow the zone as it is now; when a later swing
// point moves the level, earlier volume stays where it was counted.
fn addSlices(p, barHi, barLo, vol) {
  inner = na(rangeAvg) or na(p.wick) ? na : innerOf(p)
  // Most bars never reach the zone: one comparison and they are done.
  if not na(inner) and ((p.side == 1 and barHi >= inner) or (p.side == -1 and barLo <= inner)) {
    step = (p.lvl - inner) / 6
    for j = 0 to 5 {
      a = p.lvl - j * step
      b = a - step
      overlap = max(0.0, min(barHi, max(a, b)) - max(barLo, min(a, b)))
      share = barHi > barLo ? overlap / (barHi - barLo) : (overlap > 0 ? 1.0 : 0.0)
      arraySet(p.sv, j, arrayGet(p.sv, j) + vol * share)
    }
  }
}

// Draws a level once it has enough tests: level line and one dot per swing point.
fn drawPocket(p) {
  c = sideColor(p.side)
  if na(p.ln) {
    p.ln := lineNew(p.firstBar, p.lvl, barIndex, p.lvl, colorNew(c, tierFade(p.tier)), line.style_solid, tierWidth(p.tier), extendRight ? extend.right : extend.none)
    p.dots := arrayNew()
  } else {
    lineSetXy1(p.ln, p.firstBar, p.lvl)
    lineSetXy2(p.ln, barIndex, p.lvl)
    lineSetWidth(p.ln, tierWidth(p.tier))
    lineSetColor(p.ln, colorNew(c, tierFade(p.tier)))
  }
  if arraySize(p.ptBar) > arraySize(p.dots) {
    for k = arraySize(p.dots) to arraySize(p.ptBar) - 1 {
      arrayPush(p.dots, labelNew(arrayGet(p.ptLvl, k), "●", arrayGet(p.ptBar, k), style = label.style_none, textcolor = c, size = size.normal, tooltip = (p.side == 1 ? "Swing high #" : "Swing low #") + toString(k + 1) + " at this " + sideName(p.side)))
    }
  }
}

fn dropDots(p) {
  if not na(p.dots) and arraySize(p.dots) > 0 {
    for k = 0 to arraySize(p.dots) - 1 {
      labelDelete(arrayGet(p.dots, k))
    }
    arrayClear(p.dots)
  }
  if not na(p.note) {
    labelDelete(p.note)
    p.note := na
  }
}

fn dropLayers(p) {
  if not na(p.layers) and arraySize(p.layers) > 0 {
    for k = 0 to arraySize(p.layers) - 1 {
      boxDelete(arrayGet(p.layers, k))
    }
    arrayClear(p.layers)
  }
  if not na(p.prof) and arraySize(p.prof) > 0 {
    for k = 0 to arraySize(p.prof) - 1 {
      boxDelete(arrayGet(p.prof, k))
    }
    arrayClear(p.prof)
  }
}

fn dropDrawing(p) {
  dropDots(p)
  dropLayers(p)
  if not na(p.ln) {
    lineDelete(p.ln)
  }
  if not na(p.band) {
    boxDelete(p.band)
  }
  if not na(p.tag) {
    labelDelete(p.tag)
  }
}

// Adds a confirmed swing point to a live level nearby, or opens a new one.
fn addPoint(side, lvl, pointBar, relVol, body) {
  joined = false
  n = arraySize(pockets)
  if n > 0 {
    for i = 0 to n - 1 {
      p = arrayGet(pockets, i)
      if not joined and p.side == side and p.phase == 0 and abs(p.lvl - lvl) <= tolerance * rangeAvg {
        p.hits := p.hits + 1
        p.relVolSum := p.relVolSum + relVol
        if (side == 1 and lvl > p.lvl) or (side == -1 and lvl < p.lvl) {
          p.lvl := lvl
          p.wick := body
        }
        p.tier := tierOf(p.hits, p.relVolSum)
        arrayPush(p.ptBar, pointBar)
        arrayPush(p.ptLvl, lvl)
        if p.hits >= minTouches {
          drawPocket(p)
        }
        joined := true
      }
    }
  }
  if not joined {
    fresh = Pocket.new(lvl = lvl, side = side, firstBar = pointBar, hits = 1, relVolSum = relVol, wick = body, ptBar = arrayNewFloat(), ptLvl = arrayNewFloat(), layers = arrayNew(), sv = arrayNewFloat(6, 0.0), prof = arrayNew())
    fresh.tier := tierOf(1, relVol)
    arrayPush(fresh.ptBar, pointBar)
    arrayPush(fresh.ptLvl, lvl)
    // The bars from the swing point up to the previous bar traded at the level too;
    // the current bar is counted with the other live levels below.
    edge = side == 1 ? lvl - tolerance * rangeAvg : lvl + tolerance * rangeAvg
    for k = 1 to swingSize {
      if (side == 1 and high[k] >= edge) or (side == -1 and low[k] <= edge) {
        fresh.bandVol := fresh.bandVol + volume[k]
        fresh.buyVol := fresh.buyVol + (high[k] > low[k] ? volume[k] * (close[k] - low[k]) / (high[k] - low[k]) : volume[k] / 2)
      }
      addSlices(fresh, high[k], low[k], volume[k])
    }
    if minTouches <= 1 {
      drawPocket(fresh)
    }
    arrayPush(pockets, fresh)
    // Keep at most `perSide` live levels on this side: the weakest and oldest one is dropped.
    count = 0
    weakest = -1
    for j = 0 to arraySize(pockets) - 1 {
      q = arrayGet(pockets, j)
      if q.side == side and q.phase == 0 {
        count := count + 1
        if weakest < 0 {
          weakest := j
        } else {
          w = arrayGet(pockets, weakest)
          if w.hits > q.hits or (q.hits == w.hits and w.firstBar > q.firstBar) {
            weakest := j
          }
        }
      }
    }
    if count > perSide and weakest >= 0 {
      dropDrawing(arrayGet(pockets, weakest))
      arrayRemove(pockets, weakest)
    }
  }
}

// Records a decided break in the table.
fn record(p) {
  k = p.tier - 1
  arraySet(decided, k, arrayGet(decided, k) + 1)
  if p.verdict == 1 {
    arraySet(reclaims, k, arrayGet(reclaims, k) + 1)
  }
  if p.verdict == 2 {
    arraySet(accepts, k, arrayGet(accepts, k) + 1)
  }
  if p.loudSweep {
    loudDone := loudDone + 1
    loudReclaim := loudReclaim + (p.verdict == 1 ? 1 : 0)
    loudAccept := loudAccept + (p.verdict == 2 ? 1 : 0)
  } else {
    quietDone := quietDone + 1
    quietReclaim := quietReclaim + (p.verdict == 1 ? 1 : 0)
    quietAccept := quietAccept + (p.verdict == 2 ? 1 : 0)
  }
}

// Paints a live level: the wick zone with a bright edge at the level. `full` (the
// two nearest on each side) gets a denser zone and the volume histogram; `first`
// (the nearest on each side) a longer histogram. Others: a faint zone and a thin
// edge. Everything stops two bars after the last bar, unless it runs to the axis.
fn paintBand(p, near, full, first) {
  c = sideColor(p.side)
  dropLayers(p)
  inner = innerOf(p)
  ext = extendRight ? extend.right : extend.none
  // A light chart needs a denser fill to stand out as much as on a dark one.
  fill = (full ? (near ? 50 : 60) : 82) - (lightPanel ? 6 : 0)
  arrayPush(p.layers, boxNew(p.firstBar, max(p.lvl, inner), barIndex + 2, min(p.lvl, inner), bgcolor = colorNew(c, fill), border_color = colorNew(c, 100), border_width = 0, extend = ext))
  if full {
    paintProfile(p, inner, first)
  }
  lineSetXy2(p.ln, barIndex + 2, p.lvl)
  lineSetWidth(p.ln, full ? 2 : 1)
  lineSetColor(p.ln, colorNew(edgeOf(c), full ? 0 : 45))
}

// The volume histogram inside the zone, at its right end: six price slices with a
// thin gap between them, each as long as the volume that traded in it. The busiest
// slice is solid in the level's bright color; the others are at least a third
// opaque, more for more volume. A slice too thin for one bar is left out. It ends
// at the label, not under it.
fn paintProfile(p, inner, longer) {
  c = sideColor(p.side)
  most = 0.0
  peak = 0
  for j = 0 to 5 {
    if arrayGet(p.sv, j) > most {
      most := arrayGet(p.sv, j)
      peak := j
    }
  }
  if most > 0 {
    step = (p.lvl - inner) / 6
    room = min(longer ? 16 : 11, barIndex + 1 - p.firstBar)
    tone = lightPanel ? ink(c) : c
    for j = 0 to 5 {
      part = arrayGet(p.sv, j) / most
      a = p.lvl - (j + 0.1) * step
      b = p.lvl - (j + 0.9) * step
      slotLen = halfUp(room * part)
      if slotLen >= 1 {
        shade = j == peak ? 0 : 65 - halfUp(45 * part)
        arrayPush(p.prof, boxNew(barIndex + 1 - slotLen, max(a, b), barIndex + 1, min(a, b), bgcolor = colorNew(j == peak ? edgeOf(c) : tone, shade), border_color = colorNew(c, 100), border_width = 0))
      }
    }
  }
}

// A label just after the last bar, pointing at the level. Short lines, so it
// fits in the space before the price axis.
fn putPill(p, txt, why, strong) {
  c = sideColor(p.side)
  bg = colorNew(ink(c), strong ? 0 : 35)
  fg = "#ffffff"
  if na(p.note) {
    p.note := labelNew(p.lvl, txt, barIndex + 1, color = bg, textcolor = fg, size = size.small, style = label.style_label_left, tooltip = why)
  } else {
    labelSetXy(p.note, barIndex + 1, p.lvl)
    labelSetText(p.note, txt)
    labelSetTooltip(p.note, why)
    labelSetColor(p.note, bg)
    labelSetTextcolor(p.note, fg)
  }
}

// ── Everything below runs only from the warm-up start on ──
if barIndex >= warmStart {
rangeAvg := atr(21)
volAvg := sma(volume, 50)
inWindow = barIndex >= windowStart
hiPoint = pivothigh(swingSize, swingSize)
loPoint = pivotlow(swingSize, swingSize)

// ── New swing points ──
if inWindow and not na(rangeAvg) {
  if not na(hiPoint) {
    addPoint(1, hiPoint, barIndex - swingSize, nz(volume[swingSize] / volAvg[swingSize], 1.0), max(open[swingSize], close[swingSize]))
  }
  if not na(loPoint) {
    addPoint(-1, loPoint, barIndex - swingSize, nz(volume[swingSize] / volAvg[swingSize], 1.0), min(open[swingSize], close[swingSize]))
  }
}

// ── Live levels: break, band volume and approach ──
if inWindow and arraySize(pockets) > 0 {
  i = arraySize(pockets) - 1
  while i >= 0 {
    p = arrayGet(pockets, i)
    takenOut = p.side == 1 ? high > p.lvl : low < p.lvl
    if takenOut and minTouches > p.hits {
      // A single unconfirmed point taken out: not a level yet, nothing to record.
      dropDrawing(p)
      arrayRemove(pockets, i)
    } else if takenOut {
      p.phase := 1
      p.sweepBar := barIndex
      p.streak := 0
      p.loudSweep := volume >= 1.5 * volAvg
      dropDots(p)
      dropLayers(p)
      if not na(p.ln) {
        // A broken zone steps back: the edge and histogram go, the zone stays as a
        // faint fill without a frame, from the level's first test to the break.
        c = sideColor(p.side)
        inner = innerOf(p)
        lineDelete(p.ln)
        p.ln := na
        p.band := boxNew(p.firstBar, max(p.lvl, inner), barIndex, min(p.lvl, inner), bgcolor = colorNew(c, lightPanel ? 91 : 93), border_color = colorNew(c, 100), border_width = 0)
      }
      arrayRemove(pockets, i)
      arrayPush(pastSweeps, p)
      alert("Price broke a " + tierWord(p.tier) + " " + sideName(p.side) + " at " + toString(p.lvl), alert.freq_once_per_bar)
    } else {
      // Volume that traded inside the band, split by where the bar closed in its range.
      edge = p.side == 1 ? p.lvl - tolerance * rangeAvg : p.lvl + tolerance * rangeAvg
      if (p.side == 1 and high >= edge) or (p.side == -1 and low <= edge) {
        p.bandVol := p.bandVol + volume
        p.buyVol := p.buyVol + (high > low ? volume * (close - low) / (high - low) : volume / 2)
      }
      // Every bar that reaches into the zone adds to its histogram.
      addSlices(p, high, low, volume)
      if p.hits >= minTouches and not p.warned and abs(close - p.lvl) <= approachAtr * rangeAvg {
        p.warned := true
        alert("Price is near a " + tierWord(p.tier) + " " + sideName(p.side) + " at " + toString(p.lvl), alert.freq_once_per_bar)
      }
    }
    i := i - 1
  }
}

// ── Broken levels: wait for a decision ──
if inWindow and arraySize(pastSweeps) > 0 {
  for i = 0 to arraySize(pastSweeps) - 1 {
    p = arrayGet(pastSweeps, i)
    if p.phase == 1 {
      inside = p.side == 1 ? close < p.lvl : close > p.lvl
      waited = barIndex - p.sweepBar
      if inside {
        p.verdict := 1
        alert("Failed break: price closed back " + (p.side == 1 ? "below the resistance" : "above the support") + " at " + toString(p.lvl), alert.freq_once_per_bar)
      } else {
        p.streak := p.streak + 1
        if p.streak >= acceptCloses {
          p.verdict := 2
          alert((p.side == 1 ? "Resistance broken: " : "Support broken: ") + toString(acceptCloses) + (acceptCloses == 1 ? " close " : " closes in a row ") + (p.side == 1 ? "above " : "below ") + toString(p.lvl), alert.freq_once_per_bar)
        } else if waited >= decisionBars {
          p.verdict := 3
        }
      }
      if p.verdict > 0 {
        p.phase := 2
        dropDots(p)
        record(p)
        if markOutcomes and p.verdict != 3 and keepSwept {
          mc = p.verdict == 1 ? colReclaim : colAccept
          what = p.verdict == 1 ? "Failed break: price closed back " + (p.side == 1 ? "below the resistance" : "above the support") : (p.side == 1 ? "Resistance broken: " : "Support broken: ") + toString(acceptCloses) + (acceptCloses == 1 ? " close " : " closes in a row ") + (p.side == 1 ? "above it" : "below it")
          when = waited == 0 ? "on the breaking bar" : toString(waited) + (waited == 1 ? " bar" : " bars") + " after the break"
          why = what + "\n" + (p.verdict == 1 ? "Failed " : "Confirmed ") + when + " · " + stamp() + " UTC\n" + tierName(p.tier) + " level · tested " + timesWord(p.hits) + "\n" + (p.loudSweep ? "High" : "Low") + " volume on the break"
          // A small arrow in the direction price went after the break, in a see-through box
          // (the chart shows the tooltip of boxed labels only); the details are in the tooltip.
          p.tag := labelNew(p.lvl, arrowOf(p), barIndex, color = colorNew(PAL_GROUND, 100), style = p.side == 1 ? label.style_label_down : label.style_label_up, size = tableSize == "Small" ? size.small : tableSize == "Large" ? size.large : size.normal, textcolor = ink(mc), tooltip = why)
        }
      }
    }
  }
}

// Hide or trim the history of broken levels: the latest `keptBroken` stay on the chart.
if inWindow and arraySize(pastSweeps) > 0 and (not keepSwept or arraySize(pastSweeps) > keptBroken) {
  limit = keepSwept ? keptBroken : 0
  trimming = true
  while trimming and arraySize(pastSweeps) > limit {
    old = arrayGet(pastSweeps, 0)
    if old.phase == 2 {
      dropDrawing(old)
      arrayShift(pastSweeps)
    } else {
      trimming := false
    }
  }
}

// ── Last bar: bands, labels and the table ──
if barstate.islast {
  // Where the table goes. The last 150 bars stand in for what is on screen.
  span = min(149, barIndex)
  hi = high
  lo = low
  for k = 1 to span {
    hi := max(hi, high[k])
    lo := min(lo, low[k])
  }
  // Auto keeps the table top right. It moves top left only when a labeled live
  // resistance (one of the two nearest) or the price reaches into the part of the
  // range the table covers there: the top 10% / 15% / 24% of the 150-bar range (small / medium / large
  // table; measured on a 1600 x 1000 window, where a medium table ends 14% below
  // the top of the range).
  guard = close
  if arraySize(pockets) > 0 {
    nearestUp = na
    secondUp = na
    for j = 0 to arraySize(pockets) - 1 {
      q = arrayGet(pockets, j)
      if q.side == 1 and q.hits >= minTouches {
        if na(nearestUp) or nearestUp > q.lvl {
          secondUp := nearestUp
          nearestUp := q.lvl
        } else if na(secondUp) or secondUp > q.lvl {
          secondUp := q.lvl
        }
      }
    }
    // The second one counts only with labels on and inside the 150-bar range (its label is then on screen).
    guard := na(nearestUp) ? close : max(close, showNotes and not na(secondUp) and secondUp <= hi ? secondUp : nearestUp)
  }
  share = tableSize == "Small" ? 0.1 : tableSize == "Large" ? 0.24 : 0.15
  tablePlace := tableCorner == "Auto" ? (guard >= hi - share * (hi - lo) ? "Top left" : "Top right") : tableCorner
  waiting = 0
  if arraySize(pastSweeps) > 0 {
    for j = 0 to arraySize(pastSweeps) - 1 {
      q = arrayGet(pastSweeps, j)
      // The newest broken zone is the clearest; each older one fades a step.
      if not na(q.band) {
        age = arraySize(pastSweeps) - 1 - j
        boxSetBgColor(q.band, colorNew(sideColor(q.side), min(97, (lightPanel ? 91 : 93) + age)))
      }
      if q.phase == 1 {
        waiting := waiting + 1
        if showNotes and not na(q.band) {
          left = decisionBars - (barIndex - q.sweepBar)
          ago = barIndex - q.sweepBar
          putPill(q, "Pending break\n" + toString(left) + (left == 1 ? " bar left" : " bars left"), "Price broke this level " + toString(ago) + (ago == 1 ? " bar ago." : " bars ago.") + "\nIt fails if price closes back inside within " + toString(decisionBars) + " bars;\n" + toString(acceptCloses) + (acceptCloses == 1 ? " close" : " closes in a row") + " beyond it confirm" + (acceptCloses == 1 ? "s" : "") + " it.", false)
        }
      }
    }
  }
  // The two nearest live levels on each side get a label.
  up1 = -1
  up2 = -1
  dn1 = -1
  dn2 = -1
  if arraySize(pockets) > 0 {
    for j = 0 to arraySize(pockets) - 1 {
      q = arrayGet(pockets, j)
      if q.hits >= minTouches {
        d = abs(q.lvl - close)
        if q.side == 1 {
          if up1 < 0 or d < abs(arrayGet(pockets, up1).lvl - close) {
            up2 := up1
            up1 := j
          } else if up2 < 0 or d < abs(arrayGet(pockets, up2).lvl - close) {
            up2 := j
          }
        } else {
          if dn1 < 0 or d < abs(arrayGet(pockets, dn1).lvl - close) {
            dn2 := dn1
            dn1 := j
          } else if dn2 < 0 or d < abs(arrayGet(pockets, dn2).lvl - close) {
            dn2 := j
          }
        }
      }
    }
    for j = 0 to arraySize(pockets) - 1 {
      q = arrayGet(pockets, j)
      if q.hits >= minTouches {
        paintBand(q, abs(close - q.lvl) <= approachAtr * rangeAvg, j == up1 or j == up2 or j == dn1 or j == dn2, j == up1 or j == dn1)
      }
      if showNotes and q.hits >= minTouches and (j == up1 or j == up2 or j == dn1 or j == dn2) {
        // Resistance shows the sell share, support the buy share.
        sideVol = q.side == 1 ? q.bandVol - q.buyVol : q.buyVol
        who = q.side == 1 ? " sell volume" : " buy volume"
        short = q.side == 1 ? " selling" : " buying"
        head = q.side == 1 ? "Resistance" : "Support"
        dist = oneDecimal(abs(q.lvl - close) / close * 100)
        why = tierName(q.tier) + " " + sideName(q.side) + " at " + toString(q.lvl) + " · tested " + timesWord(q.hits) + "\n" + dist + (q.side == 1 ? "% above price" : "% below price") + "\n" + (q.bandVol > 0 ? pct(sideVol, q.bandVol) + who + " here (estimated from where bars closed)" : "No volume traded here yet") + "\nVolume traded here: " + (volAvg > 0 ? oneDecimal(q.bandVol / volAvg) : "-") + "× an average bar"
        putPill(q, head + "\ntested " + timesWord(q.hits) + (q.bandVol > 0 ? "\n" + pct(sideVol, q.bandVol) + short : ""), why, abs(close - q.lvl) <= approachAtr * rangeAvg)
      } else if not na(q.note) {
        labelDelete(q.note)
        q.note := na
      }
    }
  }
  if showProfile {
    // Top right the table starts one empty row lower, clear of the line at the top
    // of the chart that shows the bar's prices. Top left the chart itself starts it
    // below the order buttons and the indicator names, however many there are.
    top = tablePlace == "Top right" ? 1 : 0
    // The table's own ground is clear, so the empty rows stay invisible where nothing
    // covers them (a shared chart picture has no buttons or indicator name on it).
    // The chart paints a cell's ground a little see-through, so every cell is drawn
    // twice: a ground-only copy underneath, then the text. Bars and bands under the
    // table do not show through the numbers.
    if na(board) {
      boardBack := tableNew(cornerOf(tablePlace), 4, 8 + top, bgcolor = colorNew(PAL_GROUND, 100), border_width = 0, frame_width = 0)
      board := tableNew(cornerOf(tablePlace), 4, 8 + top, bgcolor = colorNew(PAL_GROUND, 100), border_width = 0, frame_width = 0)
      if top > 0 {
        for k = 0 to top - 1 {
          tableCell(boardBack, 0, k, " ", text_size = size.normal)
          tableCell(board, 0, k, " ", text_size = size.normal)
        }
      }
    }
    ground = colorNew(PAL_GROUND, 0)
    for layer = 0 to 1 {
      tb = layer == 0 ? boardBack : board
      // The copy underneath keeps the text's size (so both line up) but not its color.
      see = layer == 0 ? 100 : 0
      tableCell(tb, 0, top, "Level breaks · last " + toString(lookback) + " bars" + (waiting > 0 ? " · " + toString(waiting) + " pending" : ""), text_color = colorNew(PAL_TEXT, see), text_size = txtSize, bgcolor = ground)
      tableMergeCells(tb, 0, top, 3, top)
      tableCell(tb, 0, top + 1, "", text_size = txtSize, bgcolor = ground)
      tableCell(tb, 1, top + 1, "Breaks", text_color = colorNew(PAL_MUTED, see), text_size = txtSize, bgcolor = ground, tooltip = layer == 0 ? "" : "How many times price broke a level and the outcome was decided.")
      tableCell(tb, 2, top + 1, "Failed", text_color = colorNew(PAL_MUTED, see), text_size = txtSize, bgcolor = ground, tooltip = layer == 0 ? "" : "Price closed back inside the level within " + toString(decisionBars) + " bars of the break.")
      tableCell(tb, 3, top + 1, "Confirmed", text_color = colorNew(PAL_MUTED, see), text_size = txtSize, bgcolor = ground, tooltip = layer == 0 ? "" : toString(acceptCloses) + (acceptCloses == 1 ? " close" : " closes in a row") + " beyond the level within " + toString(decisionBars) + " bars of the break.")
      total = 0
      totalReclaim = 0
      totalAccept = 0
      for k = 0 to 5 {
        n = k < 3 ? arrayGet(decided, k) : k == 3 ? total : k == 4 ? loudDone : quietDone
        r = k < 3 ? arrayGet(reclaims, k) : k == 3 ? totalReclaim : k == 4 ? loudReclaim : quietReclaim
        a = k < 3 ? arrayGet(accepts, k) : k == 3 ? totalAccept : k == 4 ? loudAccept : quietAccept
        enough = n >= minOutcomes
        name = k < 3 ? tierName(k + 1) + " levels" : k == 3 ? "All levels" : k == 4 ? "High-volume breaks" : "Low-volume breaks"
        tableCell(tb, 0, top + 2 + k, name, text_color = colorNew(k == 3 ? PAL_MUTED : PAL_TEXT, see), text_size = txtSize, bgcolor = ground)
        tableCell(tb, 1, top + 2 + k, toString(n), text_color = colorNew(PAL_TEXT, see), text_size = txtSize, bgcolor = ground)
        few = layer == 0 ? "" : "Fewer than " + toString(minOutcomes) + " breaks: too few for a percentage."
        tableCell(tb, 2, top + 2 + k, enough ? pct(r, n) : "—", text_color = colorNew(enough ? ink(colReclaim) : PAL_MUTED, see), text_size = txtSize, bgcolor = ground, tooltip = enough ? "" : few)
        tableCell(tb, 3, top + 2 + k, enough ? pct(a, n) : "—", text_color = colorNew(enough ? ink(colAccept) : PAL_MUTED, see), text_size = txtSize, bgcolor = ground, tooltip = enough ? "" : few)
        if k < 3 {
          total := total + n
          totalReclaim := totalReclaim + r
          totalAccept := totalAccept + a
        }
      }
    }
  }
}
}

#BTC 👀
BTCUSDT · 1m · 20 Eki 202620 Eki
2 çizim · DEMA, Liquidity ClustersGrafikte aç
Kodu görüntüle
Liquidity Clusters
// © Markuda
// Groups nearby swing highs and lows into stop levels and counts how breaks of them end.

// ═══════════════════════════════════════════════════════════════════════════════
// LIQUIDITY CLUSTERS
//
// Where are stops likely to sit on this symbol, how much has traded there, and
// when price breaks a level, does the break usually fail or hold?
//
// Stops tend to rest just beyond highs and lows that held more than once. The
// indicator groups swing highs (and swing lows) that sit close to each other
// into one level. Resistance (above price) is red, support (below price) is
// green. Every swing point that formed a level is marked with a dot, so you can
// see why the level is there.
//
// Each level is drawn as a zone the height of the wick that set it: from the
// level to the body of that swing bar, where price traded and turned away (at
// least 0.15 and at most 0.6 of an average bar range). A bright line marks the
// level itself. Live levels stand out, history steps back. The two nearest live
// levels on each side get a denser zone, a 2-pixel line and a volume histogram
// at the zone's right end: the zone is cut into six price slices, each as long
// as the volume that traded in it (the busiest one is 16 bars long on the
// nearest level, 11 on the next, and solid in the level's bright color; the
// others are at least a third opaque). Other live levels keep a faint zone and a
// thin line. When price comes close to a level, its zone brightens and its label
// turns solid. Zones stop just after the last bar ("Extend levels to the right"
// runs them to the price axis).
//
// The two nearest levels on each side get a label just after the last bar:
//   Resistance / tested twice / 57% selling
//   a resistance level formed by two swing highs; of the volume traded at
//   this level, an estimated 57% was selling
//   Support / tested 3 times / 61% buying
//   a support level tested three times, where an estimated 61% of the volume
//   was buying
// Move the cursor over a label (it also gives the level's price), an arrow or a
// dot for details.
//
// When price trades beyond a level, the level is broken: its line and histogram
// go, and the zone stays as a faint fill up to the break. From that bar on the
// indicator waits a few bars for the outcome:
//   Failed break     price closed back inside the level
//   Confirmed break  price closed beyond the level several bars in a row
//   Undecided        the waiting time ran out without either
// While it waits, the level's label counts down the bars left. The outcome is
// marked with a small arrow where it was decided, pointing the way price went
// (amber = failed break, blue = confirmed break; move the cursor over it for
// details).
// It is marked then, never earlier, so nothing on the chart is redrawn later.
// Broken zones fade as newer breaks follow. The latest 6 stay on the chart ("Broken
// zones kept"); older ones still count in the table.
//
// The table counts these outcomes on this symbol and timeframe, split by level
// strength and by the volume of the breaking bar. It answers one question: on
// this chart, do breaks of strong levels usually fail, or hold?
// By default (Auto) it sits top right and moves top left only when it would
// cover a labeled resistance or the price there.
//
// Terms used on the chart and in the table
//   Level      one or more swing highs (or lows) within a small distance of each
//              other. Its price is the most extreme point of the group.
//   Band       the distance within which swing points join the level. Volume
//              inside it gives the buy / sell share on the label.
//   Zone       what is drawn for a level: from the level to the body of the
//              swing bar that set it. The histogram counts the volume traded
//              inside it, slice by slice.
//   ATR        the average bar range over the last 21 bars; every distance
//              setting is measured in it, so the same value fits any price.
//   Tested     how many swing points formed the level.
//   Strength   weak / moderate / strong, from tests and the volume on those bars.
//   Buy / sell volume  for each bar that traded inside the band, its volume is
//              split by where the bar closed in its own range (a close at the
//              high counts all of it as buying). It is an estimate from price,
//              not order data. Resistance shows the sell share, support the
//              buy share.
//   Break volume  the volume of the bar that broke the level, against the
//              50-bar average: high = 1.5× the average or more, low = less.
//
// Default values were chosen by measurement on BTCUSDT and ETHUSDT, 1h and 4h,
// 5,000 bars each (October 2026). Breaks decided per 5,000 bars:
//   swing size 5 → 49-60 · 6 → 38-53 · 8 → 27-40 · 13 → 17-20. Six keeps enough
//     breaks for the table without drawing every small wiggle.
//   level width 0.15 → 23-32 · 0.3 → 38-53 · 0.5 → 51-61. Above 0.3 separate
//     levels start to run together into one line.
//   tests needed 1: every swing point is a level, and a level that gathers
//     more points gets stronger. With 2, only 5-18 breaks per 1,000 bars were
//     decided and the table rarely had enough for a percentage; with 1, 65-82.
//   levels per side 5: 1-7 live levels per side at the last bar across the four sets.
//   bars to confirm 10: no break was left undecided in the four sets, so the
//     wait never cuts the table short.
//   break volume 1.5× average: at 1×, 95% of breaks counted as high-volume and
//     the split meant nothing; at 1.5× both groups get samples.
//   lookback 1000: enough decided breaks for a percentage on 1h and 4h, and the
//     run time stays the same however long the chart is.
//
// This is an indicator: it works only from past prices and does not know the future.
// Nothing it draws is a forecast or investment advice; it does not trade and
// places no orders.
// ═══════════════════════════════════════════════════════════════════════════════

indicator("Liquidity Clusters", "Clusters", overlay = true, max_lines_count = 500, max_labels_count = 500, max_boxes_count = 500)

// ── Inputs: what is shown, then sensitivity, then style ──
var showNotes = inputBool(true, "Level labels", group = "On the chart", tooltip = "Labels the two nearest levels above and below price: support or resistance, how many times price tested it, and the estimated buy or sell share of the volume traded there.")
var keepSwept = inputBool(true, "Keep broken levels", group = "On the chart", tooltip = "When off, broken levels disappear from the chart but still count in the table.")
var keptBroken = inputInt(6, "Broken zones kept", min = 1, max = 50, step = 1, group = "On the chart", tooltip = "How many of the latest broken zones stay on the chart, each with its outcome arrow. Older ones leave the chart but still count in the table.")
var markOutcomes = inputBool(true, "Mark break outcomes", group = "On the chart", tooltip = "A small arrow where a break failed or was confirmed, pointing the way price went. Move the cursor over it for details.")
var showProfile = inputBool(true, "Outcome table", group = "On the chart", tooltip = "Counts, for this symbol and timeframe, how often breaks of a level failed and how often they were confirmed.")
var swingSize = inputInt(6, "Swing size (bars each side)", min = 2, max = 50, step = 1, group = "Sensitivity", tooltip = "A high or low counts as a swing point when no bar within this many bars on either side goes beyond it. It is confirmed, and drawn, this many bars after it forms. Smaller values find more, smaller levels.")
var tolerance = inputFloat(0.3, "Level width (× ATR)", min = 0.05, max = 2.0, step = 0.05, group = "Sensitivity", tooltip = "Swing points closer than this join the same level. Measured in average bar ranges (ATR), so one value fits any price.")
var minTouches = inputInt(1, "Tests needed to draw a level", min = 1, max = 10, step = 1, group = "Sensitivity", tooltip = "A level is drawn and counted only after this many swing points have formed it.")
var perSide = inputInt(5, "Levels per side", min = 1, max = 25, step = 1, group = "Sensitivity", tooltip = "The most live levels kept above and below price. When full, the weakest and oldest one is dropped.")
var lookback = inputInt(1000, "Lookback (bars)", min = 300, max = 5000, step = 100, group = "Sensitivity", tooltip = "Levels are built from the most recent bars only. More bars mean more history in the table and a slower run.")
var decisionBars = inputInt(10, "Bars to confirm a break", min = 2, max = 100, step = 1, group = "Sensitivity", tooltip = "How many bars after a break the indicator waits to see whether the break fails or is confirmed.")
var acceptCloses = inputInt(2, "Closes beyond to confirm", min = 1, max = 20, step = 1, group = "Sensitivity", tooltip = "How many closes in a row beyond the level confirm the break.")
var minOutcomes = inputInt(5, "Min. breaks for a percentage", min = 1, max = 50, step = 1, group = "Sensitivity", tooltip = "A percentage is shown only after this many decided breaks. Until then the table shows a dash.")
var approachAtr = inputFloat(0.5, "Proximity (× ATR)", min = 0.1, max = 5.0, step = 0.1, group = "Sensitivity", tooltip = "When price comes this close to a live level, the level is highlighted and the proximity alert fires.")
var tableCorner = inputString("Auto", "Table position", group = "Style", tooltip = "Where the table sits. Auto keeps it top right and moves it top left only when, top right, it would cover a labeled resistance or the price.", options = ["Auto", "Top right", "Top left", "Bottom right", "Bottom left"])
var tableSize = inputString("Medium", "Table size", group = "Style", tooltip = "Text size of the table, and the size of the break outcome arrows.", options = ["Small", "Medium", "Large"])
var panelLook = inputString("Dark", "Table colors", group = "Style", tooltip = "Dark for a dark chart theme, Light for a light one. Light also makes the zones one step denser so they stand out on a light chart.", options = ["Dark", "Light"])
var extendRight = inputBool(false, "Extend levels to the right", group = "Style", tooltip = "When on, live levels and their zones run to the price axis. When off, they stop just after the last bar.")
var colAbove = inputColor("#fb5b6b", "Resistance", group = "Style", tooltip = "Color of resistance levels, their zones, histograms and labels.")
var colBelow = inputColor("#33d69f", "Support", group = "Style", tooltip = "Color of support levels, their zones, histograms and labels.")
var colReclaim = inputColor("#e3b341", "Failed break", group = "Style", tooltip = "Color of the failed break arrow and of the Failed column in the table.")
var colAccept = inputColor("#5d87c5", "Confirmed break", group = "Style", tooltip = "Color of the confirmed break arrow and of the Confirmed column in the table.")

// Table colors follow the chosen look (the chart theme cannot be read from code).
var lightPanel = panelLook == "Light"
var PAL_GROUND = lightPanel ? "#eef1f6" : "#161920"
var PAL_TEXT = lightPanel ? "#131722" : "#eceef2"
var PAL_MUTED = lightPanel ? "#6a6f7b" : "#818692"

// ── Types ──
// One group of swing points. side: 1 = highs (resistance), -1 = lows (support).
// phase: 0 forming or live, 1 broken and waiting, 2 decided.
// verdict: 1 failed break, 2 confirmed break, 3 undecided.
type Pocket {
  float lvl = na
  int side = 0
  int firstBar = 0
  int hits = 0
  float relVolSum = 0.0
  float wick = na
  float bandVol = 0.0
  float buyVol = 0.0
  int phase = 0
  int sweepBar = 0
  int streak = 0
  int verdict = 0
  int tier = 0
  bool loudSweep = false
  bool warned = false
  array ptBar = na
  array ptLvl = na
  array dots = na
  array sv = na
  array prof = na
  array layers = na
  line ln = na
  box band = na
  label note = na
  label tag = na
}

// ── Helpers ──
fn sizeOf(choice) => choice == "Small" ? size.tiny : choice == "Large" ? size.normal : size.small
fn cornerOf(choice) => choice == "Top left" ? position.top_left : choice == "Bottom right" ? position.bottom_right : choice == "Bottom left" ? position.bottom_left : position.top_right
fn tierOf(hits, relVolSum) {
  avgRel = hits > 0 ? relVolSum / hits : 0.0
  t = 1
  if hits >= 4 or (hits >= 3 and avgRel >= 1.2) {
    t := 3
  } else if hits >= 3 or (hits >= 2 and avgRel >= 1.2) {
    t := 2
  }
  return t
}
fn tierName(t) => t == 3 ? "Strong" : t == 2 ? "Moderate" : "Weak"
fn tierWord(t) => t == 3 ? "strong" : t == 2 ? "moderate" : "weak"
fn timesWord(n) => n == 1 ? "once" : n == 2 ? "twice" : toString(n) + " times"
fn tierWidth(t) => t == 3 ? 4 : t == 2 ? 2 : 1
fn tierFade(t) => t == 3 ? 0 : t == 2 ? 15 : 45
fn sideName(side) => side == 1 ? "resistance" : "support"
// Half-up rounding written out: both chart engines give the same digit on a .5 tie.
fn halfUp(x) => floor(x + 0.5)
fn pct(part, whole) => whole > 0 ? toString(halfUp(part * 100 / whole)) + "%" : "-"
fn oneDecimal(x) {
  v = halfUp(x * 10)
  return toString(floor(v / 10)) + "." + toString(v % 10)
}
fn sideColor(side) => side == 1 ? colAbove : colBelow
fn twoDigits(x) => x < 10 ? "0" + toString(x) : toString(x)
fn monthName(m) => m == 1 ? "Jan" : m == 2 ? "Feb" : m == 3 ? "Mar" : m == 4 ? "Apr" : m == 5 ? "May" : m == 6 ? "Jun" : m == 7 ? "Jul" : m == 8 ? "Aug" : m == 9 ? "Sep" : m == 10 ? "Oct" : m == 11 ? "Nov" : "Dec"
// The current bar's time written the way people read it: 7 Oct 2026, 04:00 (UTC).
fn stamp() => toString(dayofmonth) + " " + monthName(month) + " " + toString(year) + ", " + twoDigits(hour) + ":" + twoDigits(minute)
// On a light chart, text and labels take a darker shade of their color so they stay readable.
fn ink(c) => lightPanel ? colorRgb(halfUp(colorR(c) * 0.62), halfUp(colorG(c) * 0.62), halfUp(colorB(c) * 0.62)) : c
// The bright line: on a dark chart the color mixed halfway to white, on a light one its darker shade.
fn edgeOf(c) => lightPanel ? ink(c) : colorRgb(halfUp(colorR(c) * 0.5 + 127.5), halfUp(colorG(c) * 0.5 + 127.5), halfUp(colorB(c) * 0.5 + 127.5))

// The outcome arrow points the way price went after the break: down for a failed
// break of resistance, up for a confirmed one; the other way round for support.
fn arrowOf(p) => (p.side == 1) == (p.verdict == 1) ? "▼" : "▲"

// ── Measures ──
// Work happens only in the last `lookback` bars (plus a short warm-up for the
// averages). Before that a bar costs one comparison, so a 50,000-bar chart
// takes about as long as a 5,000-bar one.
var txtSize = sizeOf(tableSize)
var warmStart = lastBarIndex - lookback - 100
var windowStart = lastBarIndex - lookback
var float rangeAvg = na
var float volAvg = na
// The zone: from the level to the body of the swing bar that set it, the wick that
// price traded into and turned away from. At least 0.15 and at most 0.6 average
// bar range tall: a bar with almost no wick still shows a zone, a long one does not
// turn it into a block.
fn innerOf(p) => p.lvl - p.side * max(0.15 * rangeAvg, min(0.6 * rangeAvg, abs(p.lvl - p.wick)))

// ── State ──
var table board = na
var table boardBack = na
// The corner the table is in, decided on the last bar (Auto resolves to a top corner).
var tablePlace = ""
var pockets = arrayNew()
var pastSweeps = arrayNew()
// Table counters, index 0..2 = weak, moderate, strong.
var decided = arrayNewInt(3, 0)
var reclaims = arrayNewInt(3, 0)
var accepts = arrayNewInt(3, 0)
var loudDone = 0
var loudReclaim = 0
var loudAccept = 0
var quietDone = 0
var quietReclaim = 0
var quietAccept = 0

// Spreads a bar's volume over the zone's six price slices (slice 0 touches the
// level) by how much of the bar's range lies in each one; a bar that does not reach
// the zone adds nothing. The slices follow the zone as it is now; when a later swing
// point moves the level, earlier volume stays where it was counted.
fn addSlices(p, barHi, barLo, vol) {
  inner = na(rangeAvg) or na(p.wick) ? na : innerOf(p)
  // Most bars never reach the zone: one comparison and they are done.
  if not na(inner) and ((p.side == 1 and barHi >= inner) or (p.side == -1 and barLo <= inner)) {
    step = (p.lvl - inner) / 6
    for j = 0 to 5 {
      a = p.lvl - j * step
      b = a - step
      overlap = max(0.0, min(barHi, max(a, b)) - max(barLo, min(a, b)))
      share = barHi > barLo ? overlap / (barHi - barLo) : (overlap > 0 ? 1.0 : 0.0)
      arraySet(p.sv, j, arrayGet(p.sv, j) + vol * share)
    }
  }
}

// Draws a level once it has enough tests: level line and one dot per swing point.
fn drawPocket(p) {
  c = sideColor(p.side)
  if na(p.ln) {
    p.ln := lineNew(p.firstBar, p.lvl, barIndex, p.lvl, colorNew(c, tierFade(p.tier)), line.style_solid, tierWidth(p.tier), extendRight ? extend.right : extend.none)
    p.dots := arrayNew()
  } else {
    lineSetXy1(p.ln, p.firstBar, p.lvl)
    lineSetXy2(p.ln, barIndex, p.lvl)
    lineSetWidth(p.ln, tierWidth(p.tier))
    lineSetColor(p.ln, colorNew(c, tierFade(p.tier)))
  }
  if arraySize(p.ptBar) > arraySize(p.dots) {
    for k = arraySize(p.dots) to arraySize(p.ptBar) - 1 {
      arrayPush(p.dots, labelNew(arrayGet(p.ptLvl, k), "●", arrayGet(p.ptBar, k), style = label.style_none, textcolor = c, size = size.normal, tooltip = (p.side == 1 ? "Swing high #" : "Swing low #") + toString(k + 1) + " at this " + sideName(p.side)))
    }
  }
}

fn dropDots(p) {
  if not na(p.dots) and arraySize(p.dots) > 0 {
    for k = 0 to arraySize(p.dots) - 1 {
      labelDelete(arrayGet(p.dots, k))
    }
    arrayClear(p.dots)
  }
  if not na(p.note) {
    labelDelete(p.note)
    p.note := na
  }
}

fn dropLayers(p) {
  if not na(p.layers) and arraySize(p.layers) > 0 {
    for k = 0 to arraySize(p.layers) - 1 {
      boxDelete(arrayGet(p.layers, k))
    }
    arrayClear(p.layers)
  }
  if not na(p.prof) and arraySize(p.prof) > 0 {
    for k = 0 to arraySize(p.prof) - 1 {
      boxDelete(arrayGet(p.prof, k))
    }
    arrayClear(p.prof)
  }
}

fn dropDrawing(p) {
  dropDots(p)
  dropLayers(p)
  if not na(p.ln) {
    lineDelete(p.ln)
  }
  if not na(p.band) {
    boxDelete(p.band)
  }
  if not na(p.tag) {
    labelDelete(p.tag)
  }
}

// Adds a confirmed swing point to a live level nearby, or opens a new one.
fn addPoint(side, lvl, pointBar, relVol, body) {
  joined = false
  n = arraySize(pockets)
  if n > 0 {
    for i = 0 to n - 1 {
      p = arrayGet(pockets, i)
      if not joined and p.side == side and p.phase == 0 and abs(p.lvl - lvl) <= tolerance * rangeAvg {
        p.hits := p.hits + 1
        p.relVolSum := p.relVolSum + relVol
        if (side == 1 and lvl > p.lvl) or (side == -1 and lvl < p.lvl) {
          p.lvl := lvl
          p.wick := body
        }
        p.tier := tierOf(p.hits, p.relVolSum)
        arrayPush(p.ptBar, pointBar)
        arrayPush(p.ptLvl, lvl)
        if p.hits >= minTouches {
          drawPocket(p)
        }
        joined := true
      }
    }
  }
  if not joined {
    fresh = Pocket.new(lvl = lvl, side = side, firstBar = pointBar, hits = 1, relVolSum = relVol, wick = body, ptBar = arrayNewFloat(), ptLvl = arrayNewFloat(), layers = arrayNew(), sv = arrayNewFloat(6, 0.0), prof = arrayNew())
    fresh.tier := tierOf(1, relVol)
    arrayPush(fresh.ptBar, pointBar)
    arrayPush(fresh.ptLvl, lvl)
    // The bars from the swing point up to the previous bar traded at the level too;
    // the current bar is counted with the other live levels below.
    edge = side == 1 ? lvl - tolerance * rangeAvg : lvl + tolerance * rangeAvg
    for k = 1 to swingSize {
      if (side == 1 and high[k] >= edge) or (side == -1 and low[k] <= edge) {
        fresh.bandVol := fresh.bandVol + volume[k]
        fresh.buyVol := fresh.buyVol + (high[k] > low[k] ? volume[k] * (close[k] - low[k]) / (high[k] - low[k]) : volume[k] / 2)
      }
      addSlices(fresh, high[k], low[k], volume[k])
    }
    if minTouches <= 1 {
      drawPocket(fresh)
    }
    arrayPush(pockets, fresh)
    // Keep at most `perSide` live levels on this side: the weakest and oldest one is dropped.
    count = 0
    weakest = -1
    for j = 0 to arraySize(pockets) - 1 {
      q = arrayGet(pockets, j)
      if q.side == side and q.phase == 0 {
        count := count + 1
        if weakest < 0 {
          weakest := j
        } else {
          w = arrayGet(pockets, weakest)
          if w.hits > q.hits or (q.hits == w.hits and w.firstBar > q.firstBar) {
            weakest := j
          }
        }
      }
    }
    if count > perSide and weakest >= 0 {
      dropDrawing(arrayGet(pockets, weakest))
      arrayRemove(pockets, weakest)
    }
  }
}

// Records a decided break in the table.
fn record(p) {
  k = p.tier - 1
  arraySet(decided, k, arrayGet(decided, k) + 1)
  if p.verdict == 1 {
    arraySet(reclaims, k, arrayGet(reclaims, k) + 1)
  }
  if p.verdict == 2 {
    arraySet(accepts, k, arrayGet(accepts, k) + 1)
  }
  if p.loudSweep {
    loudDone := loudDone + 1
    loudReclaim := loudReclaim + (p.verdict == 1 ? 1 : 0)
    loudAccept := loudAccept + (p.verdict == 2 ? 1 : 0)
  } else {
    quietDone := quietDone + 1
    quietReclaim := quietReclaim + (p.verdict == 1 ? 1 : 0)
    quietAccept := quietAccept + (p.verdict == 2 ? 1 : 0)
  }
}

// Paints a live level: the wick zone with a bright edge at the level. `full` (the
// two nearest on each side) gets a denser zone and the volume histogram; `first`
// (the nearest on each side) a longer histogram. Others: a faint zone and a thin
// edge. Everything stops two bars after the last bar, unless it runs to the axis.
fn paintBand(p, near, full, first) {
  c = sideColor(p.side)
  dropLayers(p)
  inner = innerOf(p)
  ext = extendRight ? extend.right : extend.none
  // A light chart needs a denser fill to stand out as much as on a dark one.
  fill = (full ? (near ? 50 : 60) : 82) - (lightPanel ? 6 : 0)
  arrayPush(p.layers, boxNew(p.firstBar, max(p.lvl, inner), barIndex + 2, min(p.lvl, inner), bgcolor = colorNew(c, fill), border_color = colorNew(c, 100), border_width = 0, extend = ext))
  if full {
    paintProfile(p, inner, first)
  }
  lineSetXy2(p.ln, barIndex + 2, p.lvl)
  lineSetWidth(p.ln, full ? 2 : 1)
  lineSetColor(p.ln, colorNew(edgeOf(c), full ? 0 : 45))
}

// The volume histogram inside the zone, at its right end: six price slices with a
// thin gap between them, each as long as the volume that traded in it. The busiest
// slice is solid in the level's bright color; the others are at least a third
// opaque, more for more volume. A slice too thin for one bar is left out. It ends
// at the label, not under it.
fn paintProfile(p, inner, longer) {
  c = sideColor(p.side)
  most = 0.0
  peak = 0
  for j = 0 to 5 {
    if arrayGet(p.sv, j) > most {
      most := arrayGet(p.sv, j)
      peak := j
    }
  }
  if most > 0 {
    step = (p.lvl - inner) / 6
    room = min(longer ? 16 : 11, barIndex + 1 - p.firstBar)
    tone = lightPanel ? ink(c) : c
    for j = 0 to 5 {
      part = arrayGet(p.sv, j) / most
      a = p.lvl - (j + 0.1) * step
      b = p.lvl - (j + 0.9) * step
      slotLen = halfUp(room * part)
      if slotLen >= 1 {
        shade = j == peak ? 0 : 65 - halfUp(45 * part)
        arrayPush(p.prof, boxNew(barIndex + 1 - slotLen, max(a, b), barIndex + 1, min(a, b), bgcolor = colorNew(j == peak ? edgeOf(c) : tone, shade), border_color = colorNew(c, 100), border_width = 0))
      }
    }
  }
}

// A label just after the last bar, pointing at the level. Short lines, so it
// fits in the space before the price axis.
fn putPill(p, txt, why, strong) {
  c = sideColor(p.side)
  bg = colorNew(ink(c), strong ? 0 : 35)
  fg = "#ffffff"
  if na(p.note) {
    p.note := labelNew(p.lvl, txt, barIndex + 1, color = bg, textcolor = fg, size = size.small, style = label.style_label_left, tooltip = why)
  } else {
    labelSetXy(p.note, barIndex + 1, p.lvl)
    labelSetText(p.note, txt)
    labelSetTooltip(p.note, why)
    labelSetColor(p.note, bg)
    labelSetTextcolor(p.note, fg)
  }
}

// ── Everything below runs only from the warm-up start on ──
if barIndex >= warmStart {
rangeAvg := atr(21)
volAvg := sma(volume, 50)
inWindow = barIndex >= windowStart
hiPoint = pivothigh(swingSize, swingSize)
loPoint = pivotlow(swingSize, swingSize)

// ── New swing points ──
if inWindow and not na(rangeAvg) {
  if not na(hiPoint) {
    addPoint(1, hiPoint, barIndex - swingSize, nz(volume[swingSize] / volAvg[swingSize], 1.0), max(open[swingSize], close[swingSize]))
  }
  if not na(loPoint) {
    addPoint(-1, loPoint, barIndex - swingSize, nz(volume[swingSize] / volAvg[swingSize], 1.0), min(open[swingSize], close[swingSize]))
  }
}

// ── Live levels: break, band volume and approach ──
if inWindow and arraySize(pockets) > 0 {
  i = arraySize(pockets) - 1
  while i >= 0 {
    p = arrayGet(pockets, i)
    takenOut = p.side == 1 ? high > p.lvl : low < p.lvl
    if takenOut and minTouches > p.hits {
      // A single unconfirmed point taken out: not a level yet, nothing to record.
      dropDrawing(p)
      arrayRemove(pockets, i)
    } else if takenOut {
      p.phase := 1
      p.sweepBar := barIndex
      p.streak := 0
      p.loudSweep := volume >= 1.5 * volAvg
      dropDots(p)
      dropLayers(p)
      if not na(p.ln) {
        // A broken zone steps back: the edge and histogram go, the zone stays as a
        // faint fill without a frame, from the level's first test to the break.
        c = sideColor(p.side)
        inner = innerOf(p)
        lineDelete(p.ln)
        p.ln := na
        p.band := boxNew(p.firstBar, max(p.lvl, inner), barIndex, min(p.lvl, inner), bgcolor = colorNew(c, lightPanel ? 91 : 93), border_color = colorNew(c, 100), border_width = 0)
      }
      arrayRemove(pockets, i)
      arrayPush(pastSweeps, p)
      alert("Price broke a " + tierWord(p.tier) + " " + sideName(p.side) + " at " + toString(p.lvl), alert.freq_once_per_bar)
    } else {
      // Volume that traded inside the band, split by where the bar closed in its range.
      edge = p.side == 1 ? p.lvl - tolerance * rangeAvg : p.lvl + tolerance * rangeAvg
      if (p.side == 1 and high >= edge) or (p.side == -1 and low <= edge) {
        p.bandVol := p.bandVol + volume
        p.buyVol := p.buyVol + (high > low ? volume * (close - low) / (high - low) : volume / 2)
      }
      // Every bar that reaches into the zone adds to its histogram.
      addSlices(p, high, low, volume)
      if p.hits >= minTouches and not p.warned and abs(close - p.lvl) <= approachAtr * rangeAvg {
        p.warned := true
        alert("Price is near a " + tierWord(p.tier) + " " + sideName(p.side) + " at " + toString(p.lvl), alert.freq_once_per_bar)
      }
    }
    i := i - 1
  }
}

// ── Broken levels: wait for a decision ──
if inWindow and arraySize(pastSweeps) > 0 {
  for i = 0 to arraySize(pastSweeps) - 1 {
    p = arrayGet(pastSweeps, i)
    if p.phase == 1 {
      inside = p.side == 1 ? close < p.lvl : close > p.lvl
      waited = barIndex - p.sweepBar
      if inside {
        p.verdict := 1
        alert("Failed break: price closed back " + (p.side == 1 ? "below the resistance" : "above the support") + " at " + toString(p.lvl), alert.freq_once_per_bar)
      } else {
        p.streak := p.streak + 1
        if p.streak >= acceptCloses {
          p.verdict := 2
          alert((p.side == 1 ? "Resistance broken: " : "Support broken: ") + toString(acceptCloses) + (acceptCloses == 1 ? " close " : " closes in a row ") + (p.side == 1 ? "above " : "below ") + toString(p.lvl), alert.freq_once_per_bar)
        } else if waited >= decisionBars {
          p.verdict := 3
        }
      }
      if p.verdict > 0 {
        p.phase := 2
        dropDots(p)
        record(p)
        if markOutcomes and p.verdict != 3 and keepSwept {
          mc = p.verdict == 1 ? colReclaim : colAccept
          what = p.verdict == 1 ? "Failed break: price closed back " + (p.side == 1 ? "below the resistance" : "above the support") : (p.side == 1 ? "Resistance broken: " : "Support broken: ") + toString(acceptCloses) + (acceptCloses == 1 ? " close " : " closes in a row ") + (p.side == 1 ? "above it" : "below it")
          when = waited == 0 ? "on the breaking bar" : toString(waited) + (waited == 1 ? " bar" : " bars") + " after the break"
          why = what + "\n" + (p.verdict == 1 ? "Failed " : "Confirmed ") + when + " · " + stamp() + " UTC\n" + tierName(p.tier) + " level · tested " + timesWord(p.hits) + "\n" + (p.loudSweep ? "High" : "Low") + " volume on the break"
          // A small arrow in the direction price went after the break, in a see-through box
          // (the chart shows the tooltip of boxed labels only); the details are in the tooltip.
          p.tag := labelNew(p.lvl, arrowOf(p), barIndex, color = colorNew(PAL_GROUND, 100), style = p.side == 1 ? label.style_label_down : label.style_label_up, size = tableSize == "Small" ? size.small : tableSize == "Large" ? size.large : size.normal, textcolor = ink(mc), tooltip = why)
        }
      }
    }
  }
}

// Hide or trim the history of broken levels: the latest `keptBroken` stay on the chart.
if inWindow and arraySize(pastSweeps) > 0 and (not keepSwept or arraySize(pastSweeps) > keptBroken) {
  limit = keepSwept ? keptBroken : 0
  trimming = true
  while trimming and arraySize(pastSweeps) > limit {
    old = arrayGet(pastSweeps, 0)
    if old.phase == 2 {
      dropDrawing(old)
      arrayShift(pastSweeps)
    } else {
      trimming := false
    }
  }
}

// ── Last bar: bands, labels and the table ──
if barstate.islast {
  // Where the table goes. The last 150 bars stand in for what is on screen.
  span = min(149, barIndex)
  hi = high
  lo = low
  for k = 1 to span {
    hi := max(hi, high[k])
    lo := min(lo, low[k])
  }
  // Auto keeps the table top right. It moves top left only when a labeled live
  // resistance (one of the two nearest) or the price reaches into the part of the
  // range the table covers there: the top 10% / 15% / 24% of the 150-bar range (small / medium / large
  // table; measured on a 1600 x 1000 window, where a medium table ends 14% below
  // the top of the range).
  guard = close
  if arraySize(pockets) > 0 {
    nearestUp = na
    secondUp = na
    for j = 0 to arraySize(pockets) - 1 {
      q = arrayGet(pockets, j)
      if q.side == 1 and q.hits >= minTouches {
        if na(nearestUp) or nearestUp > q.lvl {
          secondUp := nearestUp
          nearestUp := q.lvl
        } else if na(secondUp) or secondUp > q.lvl {
          secondUp := q.lvl
        }
      }
    }
    // The second one counts only with labels on and inside the 150-bar range (its label is then on screen).
    guard := na(nearestUp) ? close : max(close, showNotes and not na(secondUp) and secondUp <= hi ? secondUp : nearestUp)
  }
  share = tableSize == "Small" ? 0.1 : tableSize == "Large" ? 0.24 : 0.15
  tablePlace := tableCorner == "Auto" ? (guard >= hi - share * (hi - lo) ? "Top left" : "Top right") : tableCorner
  waiting = 0
  if arraySize(pastSweeps) > 0 {
    for j = 0 to arraySize(pastSweeps) - 1 {
      q = arrayGet(pastSweeps, j)
      // The newest broken zone is the clearest; each older one fades a step.
      if not na(q.band) {
        age = arraySize(pastSweeps) - 1 - j
        boxSetBgColor(q.band, colorNew(sideColor(q.side), min(97, (lightPanel ? 91 : 93) + age)))
      }
      if q.phase == 1 {
        waiting := waiting + 1
        if showNotes and not na(q.band) {
          left = decisionBars - (barIndex - q.sweepBar)
          ago = barIndex - q.sweepBar
          putPill(q, "Pending break\n" + toString(left) + (left == 1 ? " bar left" : " bars left"), "Price broke this level " + toString(ago) + (ago == 1 ? " bar ago." : " bars ago.") + "\nIt fails if price closes back inside within " + toString(decisionBars) + " bars;\n" + toString(acceptCloses) + (acceptCloses == 1 ? " close" : " closes in a row") + " beyond it confirm" + (acceptCloses == 1 ? "s" : "") + " it.", false)
        }
      }
    }
  }
  // The two nearest live levels on each side get a label.
  up1 = -1
  up2 = -1
  dn1 = -1
  dn2 = -1
  if arraySize(pockets) > 0 {
    for j = 0 to arraySize(pockets) - 1 {
      q = arrayGet(pockets, j)
      if q.hits >= minTouches {
        d = abs(q.lvl - close)
        if q.side == 1 {
          if up1 < 0 or d < abs(arrayGet(pockets, up1).lvl - close) {
            up2 := up1
            up1 := j
          } else if up2 < 0 or d < abs(arrayGet(pockets, up2).lvl - close) {
            up2 := j
          }
        } else {
          if dn1 < 0 or d < abs(arrayGet(pockets, dn1).lvl - close) {
            dn2 := dn1
            dn1 := j
          } else if dn2 < 0 or d < abs(arrayGet(pockets, dn2).lvl - close) {
            dn2 := j
          }
        }
      }
    }
    for j = 0 to arraySize(pockets) - 1 {
      q = arrayGet(pockets, j)
      if q.hits >= minTouches {
        paintBand(q, abs(close - q.lvl) <= approachAtr * rangeAvg, j == up1 or j == up2 or j == dn1 or j == dn2, j == up1 or j == dn1)
      }
      if showNotes and q.hits >= minTouches and (j == up1 or j == up2 or j == dn1 or j == dn2) {
        // Resistance shows the sell share, support the buy share.
        sideVol = q.side == 1 ? q.bandVol - q.buyVol : q.buyVol
        who = q.side == 1 ? " sell volume" : " buy volume"
        short = q.side == 1 ? " selling" : " buying"
        head = q.side == 1 ? "Resistance" : "Support"
        dist = oneDecimal(abs(q.lvl - close) / close * 100)
        why = tierName(q.tier) + " " + sideName(q.side) + " at " + toString(q.lvl) + " · tested " + timesWord(q.hits) + "\n" + dist + (q.side == 1 ? "% above price" : "% below price") + "\n" + (q.bandVol > 0 ? pct(sideVol, q.bandVol) + who + " here (estimated from where bars closed)" : "No volume traded here yet") + "\nVolume traded here: " + (volAvg > 0 ? oneDecimal(q.bandVol / volAvg) : "-") + "× an average bar"
        putPill(q, head + "\ntested " + timesWord(q.hits) + (q.bandVol > 0 ? "\n" + pct(sideVol, q.bandVol) + short : ""), why, abs(close - q.lvl) <= approachAtr * rangeAvg)
      } else if not na(q.note) {
        labelDelete(q.note)
        q.note := na
      }
    }
  }
  if showProfile {
    // Top right the table starts one empty row lower, clear of the line at the top
    // of the chart that shows the bar's prices. Top left the chart itself starts it
    // below the order buttons and the indicator names, however many there are.
    top = tablePlace == "Top right" ? 1 : 0
    // The table's own ground is clear, so the empty rows stay invisible where nothing
    // covers them (a shared chart picture has no buttons or indicator name on it).
    // The chart paints a cell's ground a little see-through, so every cell is drawn
    // twice: a ground-only copy underneath, then the text. Bars and bands under the
    // table do not show through the numbers.
    if na(board) {
      boardBack := tableNew(cornerOf(tablePlace), 4, 8 + top, bgcolor = colorNew(PAL_GROUND, 100), border_width = 0, frame_width = 0)
      board := tableNew(cornerOf(tablePlace), 4, 8 + top, bgcolor = colorNew(PAL_GROUND, 100), border_width = 0, frame_width = 0)
      if top > 0 {
        for k = 0 to top - 1 {
          tableCell(boardBack, 0, k, " ", text_size = size.normal)
          tableCell(board, 0, k, " ", text_size = size.normal)
        }
      }
    }
    ground = colorNew(PAL_GROUND, 0)
    for layer = 0 to 1 {
      tb = layer == 0 ? boardBack : board
      // The copy underneath keeps the text's size (so both line up) but not its color.
      see = layer == 0 ? 100 : 0
      tableCell(tb, 0, top, "Level breaks · last " + toString(lookback) + " bars" + (waiting > 0 ? " · " + toString(waiting) + " pending" : ""), text_color = colorNew(PAL_TEXT, see), text_size = txtSize, bgcolor = ground)
      tableMergeCells(tb, 0, top, 3, top)
      tableCell(tb, 0, top + 1, "", text_size = txtSize, bgcolor = ground)
      tableCell(tb, 1, top + 1, "Breaks", text_color = colorNew(PAL_MUTED, see), text_size = txtSize, bgcolor = ground, tooltip = layer == 0 ? "" : "How many times price broke a level and the outcome was decided.")
      tableCell(tb, 2, top + 1, "Failed", text_color = colorNew(PAL_MUTED, see), text_size = txtSize, bgcolor = ground, tooltip = layer == 0 ? "" : "Price closed back inside the level within " + toString(decisionBars) + " bars of the break.")
      tableCell(tb, 3, top + 1, "Confirmed", text_color = colorNew(PAL_MUTED, see), text_size = txtSize, bgcolor = ground, tooltip = layer == 0 ? "" : toString(acceptCloses) + (acceptCloses == 1 ? " close" : " closes in a row") + " beyond the level within " + toString(decisionBars) + " bars of the break.")
      total = 0
      totalReclaim = 0
      totalAccept = 0
      for k = 0 to 5 {
        n = k < 3 ? arrayGet(decided, k) : k == 3 ? total : k == 4 ? loudDone : quietDone
        r = k < 3 ? arrayGet(reclaims, k) : k == 3 ? totalReclaim : k == 4 ? loudReclaim : quietReclaim
        a = k < 3 ? arrayGet(accepts, k) : k == 3 ? totalAccept : k == 4 ? loudAccept : quietAccept
        enough = n >= minOutcomes
        name = k < 3 ? tierName(k + 1) + " levels" : k == 3 ? "All levels" : k == 4 ? "High-volume breaks" : "Low-volume breaks"
        tableCell(tb, 0, top + 2 + k, name, text_color = colorNew(k == 3 ? PAL_MUTED : PAL_TEXT, see), text_size = txtSize, bgcolor = ground)
        tableCell(tb, 1, top + 2 + k, toString(n), text_color = colorNew(PAL_TEXT, see), text_size = txtSize, bgcolor = ground)
        few = layer == 0 ? "" : "Fewer than " + toString(minOutcomes) + " breaks: too few for a percentage."
        tableCell(tb, 2, top + 2 + k, enough ? pct(r, n) : "—", text_color = colorNew(enough ? ink(colReclaim) : PAL_MUTED, see), text_size = txtSize, bgcolor = ground, tooltip = enough ? "" : few)
        tableCell(tb, 3, top + 2 + k, enough ? pct(a, n) : "—", text_color = colorNew(enough ? ink(colAccept) : PAL_MUTED, see), text_size = txtSize, bgcolor = ground, tooltip = enough ? "" : few)
        if k < 3 {
          total := total + n
          totalReclaim := totalReclaim + r
          totalAccept := totalAccept + a
        }
      }
    }
  }
}
}

The entire market has taken off; Sparkassen, Europe’s largest banking network, has launched cryptocurrency trading services for BTC, ETH, and Ripple.
ZECUSDT · 15m · 20 Eki 202620 Eki
1 çizim · DEMA, Liquidity ClustersGrafikte aç
Kodu görüntüle
Liquidity Clusters
// © Markuda
// Groups nearby swing highs and lows into stop levels and counts how breaks of them end.

// ═══════════════════════════════════════════════════════════════════════════════
// LIQUIDITY CLUSTERS
//
// Where are stops likely to sit on this symbol, how much has traded there, and
// when price breaks a level, does the break usually fail or hold?
//
// Stops tend to rest just beyond highs and lows that held more than once. The
// indicator groups swing highs (and swing lows) that sit close to each other
// into one level. Resistance (above price) is red, support (below price) is
// green. Every swing point that formed a level is marked with a dot, so you can
// see why the level is there.
//
// Each level is drawn as a zone the height of the wick that set it: from the
// level to the body of that swing bar, where price traded and turned away (at
// least 0.15 and at most 0.6 of an average bar range). A bright line marks the
// level itself. Live levels stand out, history steps back. The two nearest live
// levels on each side get a denser zone, a 2-pixel line and a volume histogram
// at the zone's right end: the zone is cut into six price slices, each as long
// as the volume that traded in it (the busiest one is 16 bars long on the
// nearest level, 11 on the next, and solid in the level's bright color; the
// others are at least a third opaque). Other live levels keep a faint zone and a
// thin line. When price comes close to a level, its zone brightens and its label
// turns solid. Zones stop just after the last bar ("Extend levels to the right"
// runs them to the price axis).
//
// The two nearest levels on each side get a label just after the last bar:
//   Resistance / tested twice / 57% selling
//   a resistance level formed by two swing highs; of the volume traded at
//   this level, an estimated 57% was selling
//   Support / tested 3 times / 61% buying
//   a support level tested three times, where an estimated 61% of the volume
//   was buying
// Move the cursor over a label (it also gives the level's price), an arrow or a
// dot for details.
//
// When price trades beyond a level, the level is broken: its line and histogram
// go, and the zone stays as a faint fill up to the break. From that bar on the
// indicator waits a few bars for the outcome:
//   Failed break     price closed back inside the level
//   Confirmed break  price closed beyond the level several bars in a row
//   Undecided        the waiting time ran out without either
// While it waits, the level's label counts down the bars left. The outcome is
// marked with a small arrow where it was decided, pointing the way price went
// (amber = failed break, blue = confirmed break; move the cursor over it for
// details).
// It is marked then, never earlier, so nothing on the chart is redrawn later.
// Broken zones fade as newer breaks follow. The latest 6 stay on the chart ("Broken
// zones kept"); older ones still count in the table.
//
// The table counts these outcomes on this symbol and timeframe, split by level
// strength and by the volume of the breaking bar. It answers one question: on
// this chart, do breaks of strong levels usually fail, or hold?
// By default (Auto) it sits top right and moves top left only when it would
// cover a labeled resistance or the price there.
//
// Terms used on the chart and in the table
//   Level      one or more swing highs (or lows) within a small distance of each
//              other. Its price is the most extreme point of the group.
//   Band       the distance within which swing points join the level. Volume
//              inside it gives the buy / sell share on the label.
//   Zone       what is drawn for a level: from the level to the body of the
//              swing bar that set it. The histogram counts the volume traded
//              inside it, slice by slice.
//   ATR        the average bar range over the last 21 bars; every distance
//              setting is measured in it, so the same value fits any price.
//   Tested     how many swing points formed the level.
//   Strength   weak / moderate / strong, from tests and the volume on those bars.
//   Buy / sell volume  for each bar that traded inside the band, its volume is
//              split by where the bar closed in its own range (a close at the
//              high counts all of it as buying). It is an estimate from price,
//              not order data. Resistance shows the sell share, support the
//              buy share.
//   Break volume  the volume of the bar that broke the level, against the
//              50-bar average: high = 1.5× the average or more, low = less.
//
// Default values were chosen by measurement on BTCUSDT and ETHUSDT, 1h and 4h,
// 5,000 bars each (October 2026). Breaks decided per 5,000 bars:
//   swing size 5 → 49-60 · 6 → 38-53 · 8 → 27-40 · 13 → 17-20. Six keeps enough
//     breaks for the table without drawing every small wiggle.
//   level width 0.15 → 23-32 · 0.3 → 38-53 · 0.5 → 51-61. Above 0.3 separate
//     levels start to run together into one line.
//   tests needed 1: every swing point is a level, and a level that gathers
//     more points gets stronger. With 2, only 5-18 breaks per 1,000 bars were
//     decided and the table rarely had enough for a percentage; with 1, 65-82.
//   levels per side 5: 1-7 live levels per side at the last bar across the four sets.
//   bars to confirm 10: no break was left undecided in the four sets, so the
//     wait never cuts the table short.
//   break volume 1.5× average: at 1×, 95% of breaks counted as high-volume and
//     the split meant nothing; at 1.5× both groups get samples.
//   lookback 1000: enough decided breaks for a percentage on 1h and 4h, and the
//     run time stays the same however long the chart is.
//
// This is an indicator: it works only from past prices and does not know the future.
// Nothing it draws is a forecast or investment advice; it does not trade and
// places no orders.
// ═══════════════════════════════════════════════════════════════════════════════

indicator("Liquidity Clusters", "Clusters", overlay = true, max_lines_count = 500, max_labels_count = 500, max_boxes_count = 500)

// ── Inputs: what is shown, then sensitivity, then style ──
var showNotes = inputBool(true, "Level labels", group = "On the chart", tooltip = "Labels the two nearest levels above and below price: support or resistance, how many times price tested it, and the estimated buy or sell share of the volume traded there.")
var keepSwept = inputBool(true, "Keep broken levels", group = "On the chart", tooltip = "When off, broken levels disappear from the chart but still count in the table.")
var keptBroken = inputInt(6, "Broken zones kept", min = 1, max = 50, step = 1, group = "On the chart", tooltip = "How many of the latest broken zones stay on the chart, each with its outcome arrow. Older ones leave the chart but still count in the table.")
var markOutcomes = inputBool(true, "Mark break outcomes", group = "On the chart", tooltip = "A small arrow where a break failed or was confirmed, pointing the way price went. Move the cursor over it for details.")
var showProfile = inputBool(true, "Outcome table", group = "On the chart", tooltip = "Counts, for this symbol and timeframe, how often breaks of a level failed and how often they were confirmed.")
var swingSize = inputInt(6, "Swing size (bars each side)", min = 2, max = 50, step = 1, group = "Sensitivity", tooltip = "A high or low counts as a swing point when no bar within this many bars on either side goes beyond it. It is confirmed, and drawn, this many bars after it forms. Smaller values find more, smaller levels.")
var tolerance = inputFloat(0.3, "Level width (× ATR)", min = 0.05, max = 2.0, step = 0.05, group = "Sensitivity", tooltip = "Swing points closer than this join the same level. Measured in average bar ranges (ATR), so one value fits any price.")
var minTouches = inputInt(1, "Tests needed to draw a level", min = 1, max = 10, step = 1, group = "Sensitivity", tooltip = "A level is drawn and counted only after this many swing points have formed it.")
var perSide = inputInt(5, "Levels per side", min = 1, max = 25, step = 1, group = "Sensitivity", tooltip = "The most live levels kept above and below price. When full, the weakest and oldest one is dropped.")
var lookback = inputInt(1000, "Lookback (bars)", min = 300, max = 5000, step = 100, group = "Sensitivity", tooltip = "Levels are built from the most recent bars only. More bars mean more history in the table and a slower run.")
var decisionBars = inputInt(10, "Bars to confirm a break", min = 2, max = 100, step = 1, group = "Sensitivity", tooltip = "How many bars after a break the indicator waits to see whether the break fails or is confirmed.")
var acceptCloses = inputInt(2, "Closes beyond to confirm", min = 1, max = 20, step = 1, group = "Sensitivity", tooltip = "How many closes in a row beyond the level confirm the break.")
var minOutcomes = inputInt(5, "Min. breaks for a percentage", min = 1, max = 50, step = 1, group = "Sensitivity", tooltip = "A percentage is shown only after this many decided breaks. Until then the table shows a dash.")
var approachAtr = inputFloat(0.5, "Proximity (× ATR)", min = 0.1, max = 5.0, step = 0.1, group = "Sensitivity", tooltip = "When price comes this close to a live level, the level is highlighted and the proximity alert fires.")
var tableCorner = inputString("Auto", "Table position", group = "Style", tooltip = "Where the table sits. Auto keeps it top right and moves it top left only when, top right, it would cover a labeled resistance or the price.", options = ["Auto", "Top right", "Top left", "Bottom right", "Bottom left"])
var tableSize = inputString("Medium", "Table size", group = "Style", tooltip = "Text size of the table, and the size of the break outcome arrows.", options = ["Small", "Medium", "Large"])
var panelLook = inputString("Dark", "Table colors", group = "Style", tooltip = "Dark for a dark chart theme, Light for a light one. Light also makes the zones one step denser so they stand out on a light chart.", options = ["Dark", "Light"])
var extendRight = inputBool(false, "Extend levels to the right", group = "Style", tooltip = "When on, live levels and their zones run to the price axis. When off, they stop just after the last bar.")
var colAbove = inputColor("#fb5b6b", "Resistance", group = "Style", tooltip = "Color of resistance levels, their zones, histograms and labels.")
var colBelow = inputColor("#33d69f", "Support", group = "Style", tooltip = "Color of support levels, their zones, histograms and labels.")
var colReclaim = inputColor("#e3b341", "Failed break", group = "Style", tooltip = "Color of the failed break arrow and of the Failed column in the table.")
var colAccept = inputColor("#5d87c5", "Confirmed break", group = "Style", tooltip = "Color of the confirmed break arrow and of the Confirmed column in the table.")

// Table colors follow the chosen look (the chart theme cannot be read from code).
var lightPanel = panelLook == "Light"
var PAL_GROUND = lightPanel ? "#eef1f6" : "#161920"
var PAL_TEXT = lightPanel ? "#131722" : "#eceef2"
var PAL_MUTED = lightPanel ? "#6a6f7b" : "#818692"

// ── Types ──
// One group of swing points. side: 1 = highs (resistance), -1 = lows (support).
// phase: 0 forming or live, 1 broken and waiting, 2 decided.
// verdict: 1 failed break, 2 confirmed break, 3 undecided.
type Pocket {
  float lvl = na
  int side = 0
  int firstBar = 0
  int hits = 0
  float relVolSum = 0.0
  float wick = na
  float bandVol = 0.0
  float buyVol = 0.0
  int phase = 0
  int sweepBar = 0
  int streak = 0
  int verdict = 0
  int tier = 0
  bool loudSweep = false
  bool warned = false
  array ptBar = na
  array ptLvl = na
  array dots = na
  array sv = na
  array prof = na
  array layers = na
  line ln = na
  box band = na
  label note = na
  label tag = na
}

// ── Helpers ──
fn sizeOf(choice) => choice == "Small" ? size.tiny : choice == "Large" ? size.normal : size.small
fn cornerOf(choice) => choice == "Top left" ? position.top_left : choice == "Bottom right" ? position.bottom_right : choice == "Bottom left" ? position.bottom_left : position.top_right
fn tierOf(hits, relVolSum) {
  avgRel = hits > 0 ? relVolSum / hits : 0.0
  t = 1
  if hits >= 4 or (hits >= 3 and avgRel >= 1.2) {
    t := 3
  } else if hits >= 3 or (hits >= 2 and avgRel >= 1.2) {
    t := 2
  }
  return t
}
fn tierName(t) => t == 3 ? "Strong" : t == 2 ? "Moderate" : "Weak"
fn tierWord(t) => t == 3 ? "strong" : t == 2 ? "moderate" : "weak"
fn timesWord(n) => n == 1 ? "once" : n == 2 ? "twice" : toString(n) + " times"
fn tierWidth(t) => t == 3 ? 4 : t == 2 ? 2 : 1
fn tierFade(t) => t == 3 ? 0 : t == 2 ? 15 : 45
fn sideName(side) => side == 1 ? "resistance" : "support"
// Half-up rounding written out: both chart engines give the same digit on a .5 tie.
fn halfUp(x) => floor(x + 0.5)
fn pct(part, whole) => whole > 0 ? toString(halfUp(part * 100 / whole)) + "%" : "-"
fn oneDecimal(x) {
  v = halfUp(x * 10)
  return toString(floor(v / 10)) + "." + toString(v % 10)
}
fn sideColor(side) => side == 1 ? colAbove : colBelow
fn twoDigits(x) => x < 10 ? "0" + toString(x) : toString(x)
fn monthName(m) => m == 1 ? "Jan" : m == 2 ? "Feb" : m == 3 ? "Mar" : m == 4 ? "Apr" : m == 5 ? "May" : m == 6 ? "Jun" : m == 7 ? "Jul" : m == 8 ? "Aug" : m == 9 ? "Sep" : m == 10 ? "Oct" : m == 11 ? "Nov" : "Dec"
// The current bar's time written the way people read it: 7 Oct 2026, 04:00 (UTC).
fn stamp() => toString(dayofmonth) + " " + monthName(month) + " " + toString(year) + ", " + twoDigits(hour) + ":" + twoDigits(minute)
// On a light chart, text and labels take a darker shade of their color so they stay readable.
fn ink(c) => lightPanel ? colorRgb(halfUp(colorR(c) * 0.62), halfUp(colorG(c) * 0.62), halfUp(colorB(c) * 0.62)) : c
// The bright line: on a dark chart the color mixed halfway to white, on a light one its darker shade.
fn edgeOf(c) => lightPanel ? ink(c) : colorRgb(halfUp(colorR(c) * 0.5 + 127.5), halfUp(colorG(c) * 0.5 + 127.5), halfUp(colorB(c) * 0.5 + 127.5))

// The outcome arrow points the way price went after the break: down for a failed
// break of resistance, up for a confirmed one; the other way round for support.
fn arrowOf(p) => (p.side == 1) == (p.verdict == 1) ? "▼" : "▲"

// ── Measures ──
// Work happens only in the last `lookback` bars (plus a short warm-up for the
// averages). Before that a bar costs one comparison, so a 50,000-bar chart
// takes about as long as a 5,000-bar one.
var txtSize = sizeOf(tableSize)
var warmStart = lastBarIndex - lookback - 100
var windowStart = lastBarIndex - lookback
var float rangeAvg = na
var float volAvg = na
// The zone: from the level to the body of the swing bar that set it, the wick that
// price traded into and turned away from. At least 0.15 and at most 0.6 average
// bar range tall: a bar with almost no wick still shows a zone, a long one does not
// turn it into a block.
fn innerOf(p) => p.lvl - p.side * max(0.15 * rangeAvg, min(0.6 * rangeAvg, abs(p.lvl - p.wick)))

// ── State ──
var table board = na
var table boardBack = na
// The corner the table is in, decided on the last bar (Auto resolves to a top corner).
var tablePlace = ""
var pockets = arrayNew()
var pastSweeps = arrayNew()
// Table counters, index 0..2 = weak, moderate, strong.
var decided = arrayNewInt(3, 0)
var reclaims = arrayNewInt(3, 0)
var accepts = arrayNewInt(3, 0)
var loudDone = 0
var loudReclaim = 0
var loudAccept = 0
var quietDone = 0
var quietReclaim = 0
var quietAccept = 0

// Spreads a bar's volume over the zone's six price slices (slice 0 touches the
// level) by how much of the bar's range lies in each one; a bar that does not reach
// the zone adds nothing. The slices follow the zone as it is now; when a later swing
// point moves the level, earlier volume stays where it was counted.
fn addSlices(p, barHi, barLo, vol) {
  inner = na(rangeAvg) or na(p.wick) ? na : innerOf(p)
  // Most bars never reach the zone: one comparison and they are done.
  if not na(inner) and ((p.side == 1 and barHi >= inner) or (p.side == -1 and barLo <= inner)) {
    step = (p.lvl - inner) / 6
    for j = 0 to 5 {
      a = p.lvl - j * step
      b = a - step
      overlap = max(0.0, min(barHi, max(a, b)) - max(barLo, min(a, b)))
      share = barHi > barLo ? overlap / (barHi - barLo) : (overlap > 0 ? 1.0 : 0.0)
      arraySet(p.sv, j, arrayGet(p.sv, j) + vol * share)
    }
  }
}

// Draws a level once it has enough tests: level line and one dot per swing point.
fn drawPocket(p) {
  c = sideColor(p.side)
  if na(p.ln) {
    p.ln := lineNew(p.firstBar, p.lvl, barIndex, p.lvl, colorNew(c, tierFade(p.tier)), line.style_solid, tierWidth(p.tier), extendRight ? extend.right : extend.none)
    p.dots := arrayNew()
  } else {
    lineSetXy1(p.ln, p.firstBar, p.lvl)
    lineSetXy2(p.ln, barIndex, p.lvl)
    lineSetWidth(p.ln, tierWidth(p.tier))
    lineSetColor(p.ln, colorNew(c, tierFade(p.tier)))
  }
  if arraySize(p.ptBar) > arraySize(p.dots) {
    for k = arraySize(p.dots) to arraySize(p.ptBar) - 1 {
      arrayPush(p.dots, labelNew(arrayGet(p.ptLvl, k), "●", arrayGet(p.ptBar, k), style = label.style_none, textcolor = c, size = size.normal, tooltip = (p.side == 1 ? "Swing high #" : "Swing low #") + toString(k + 1) + " at this " + sideName(p.side)))
    }
  }
}

fn dropDots(p) {
  if not na(p.dots) and arraySize(p.dots) > 0 {
    for k = 0 to arraySize(p.dots) - 1 {
      labelDelete(arrayGet(p.dots, k))
    }
    arrayClear(p.dots)
  }
  if not na(p.note) {
    labelDelete(p.note)
    p.note := na
  }
}

fn dropLayers(p) {
  if not na(p.layers) and arraySize(p.layers) > 0 {
    for k = 0 to arraySize(p.layers) - 1 {
      boxDelete(arrayGet(p.layers, k))
    }
    arrayClear(p.layers)
  }
  if not na(p.prof) and arraySize(p.prof) > 0 {
    for k = 0 to arraySize(p.prof) - 1 {
      boxDelete(arrayGet(p.prof, k))
    }
    arrayClear(p.prof)
  }
}

fn dropDrawing(p) {
  dropDots(p)
  dropLayers(p)
  if not na(p.ln) {
    lineDelete(p.ln)
  }
  if not na(p.band) {
    boxDelete(p.band)
  }
  if not na(p.tag) {
    labelDelete(p.tag)
  }
}

// Adds a confirmed swing point to a live level nearby, or opens a new one.
fn addPoint(side, lvl, pointBar, relVol, body) {
  joined = false
  n = arraySize(pockets)
  if n > 0 {
    for i = 0 to n - 1 {
      p = arrayGet(pockets, i)
      if not joined and p.side == side and p.phase == 0 and abs(p.lvl - lvl) <= tolerance * rangeAvg {
        p.hits := p.hits + 1
        p.relVolSum := p.relVolSum + relVol
        if (side == 1 and lvl > p.lvl) or (side == -1 and lvl < p.lvl) {
          p.lvl := lvl
          p.wick := body
        }
        p.tier := tierOf(p.hits, p.relVolSum)
        arrayPush(p.ptBar, pointBar)
        arrayPush(p.ptLvl, lvl)
        if p.hits >= minTouches {
          drawPocket(p)
        }
        joined := true
      }
    }
  }
  if not joined {
    fresh = Pocket.new(lvl = lvl, side = side, firstBar = pointBar, hits = 1, relVolSum = relVol, wick = body, ptBar = arrayNewFloat(), ptLvl = arrayNewFloat(), layers = arrayNew(), sv = arrayNewFloat(6, 0.0), prof = arrayNew())
    fresh.tier := tierOf(1, relVol)
    arrayPush(fresh.ptBar, pointBar)
    arrayPush(fresh.ptLvl, lvl)
    // The bars from the swing point up to the previous bar traded at the level too;
    // the current bar is counted with the other live levels below.
    edge = side == 1 ? lvl - tolerance * rangeAvg : lvl + tolerance * rangeAvg
    for k = 1 to swingSize {
      if (side == 1 and high[k] >= edge) or (side == -1 and low[k] <= edge) {
        fresh.bandVol := fresh.bandVol + volume[k]
        fresh.buyVol := fresh.buyVol + (high[k] > low[k] ? volume[k] * (close[k] - low[k]) / (high[k] - low[k]) : volume[k] / 2)
      }
      addSlices(fresh, high[k], low[k], volume[k])
    }
    if minTouches <= 1 {
      drawPocket(fresh)
    }
    arrayPush(pockets, fresh)
    // Keep at most `perSide` live levels on this side: the weakest and oldest one is dropped.
    count = 0
    weakest = -1
    for j = 0 to arraySize(pockets) - 1 {
      q = arrayGet(pockets, j)
      if q.side == side and q.phase == 0 {
        count := count + 1
        if weakest < 0 {
          weakest := j
        } else {
          w = arrayGet(pockets, weakest)
          if w.hits > q.hits or (q.hits == w.hits and w.firstBar > q.firstBar) {
            weakest := j
          }
        }
      }
    }
    if count > perSide and weakest >= 0 {
      dropDrawing(arrayGet(pockets, weakest))
      arrayRemove(pockets, weakest)
    }
  }
}

// Records a decided break in the table.
fn record(p) {
  k = p.tier - 1
  arraySet(decided, k, arrayGet(decided, k) + 1)
  if p.verdict == 1 {
    arraySet(reclaims, k, arrayGet(reclaims, k) + 1)
  }
  if p.verdict == 2 {
    arraySet(accepts, k, arrayGet(accepts, k) + 1)
  }
  if p.loudSweep {
    loudDone := loudDone + 1
    loudReclaim := loudReclaim + (p.verdict == 1 ? 1 : 0)
    loudAccept := loudAccept + (p.verdict == 2 ? 1 : 0)
  } else {
    quietDone := quietDone + 1
    quietReclaim := quietReclaim + (p.verdict == 1 ? 1 : 0)
    quietAccept := quietAccept + (p.verdict == 2 ? 1 : 0)
  }
}

// Paints a live level: the wick zone with a bright edge at the level. `full` (the
// two nearest on each side) gets a denser zone and the volume histogram; `first`
// (the nearest on each side) a longer histogram. Others: a faint zone and a thin
// edge. Everything stops two bars after the last bar, unless it runs to the axis.
fn paintBand(p, near, full, first) {
  c = sideColor(p.side)
  dropLayers(p)
  inner = innerOf(p)
  ext = extendRight ? extend.right : extend.none
  // A light chart needs a denser fill to stand out as much as on a dark one.
  fill = (full ? (near ? 50 : 60) : 82) - (lightPanel ? 6 : 0)
  arrayPush(p.layers, boxNew(p.firstBar, max(p.lvl, inner), barIndex + 2, min(p.lvl, inner), bgcolor = colorNew(c, fill), border_color = colorNew(c, 100), border_width = 0, extend = ext))
  if full {
    paintProfile(p, inner, first)
  }
  lineSetXy2(p.ln, barIndex + 2, p.lvl)
  lineSetWidth(p.ln, full ? 2 : 1)
  lineSetColor(p.ln, colorNew(edgeOf(c), full ? 0 : 45))
}

// The volume histogram inside the zone, at its right end: six price slices with a
// thin gap between them, each as long as the volume that traded in it. The busiest
// slice is solid in the level's bright color; the others are at least a third
// opaque, more for more volume. A slice too thin for one bar is left out. It ends
// at the label, not under it.
fn paintProfile(p, inner, longer) {
  c = sideColor(p.side)
  most = 0.0
  peak = 0
  for j = 0 to 5 {
    if arrayGet(p.sv, j) > most {
      most := arrayGet(p.sv, j)
      peak := j
    }
  }
  if most > 0 {
    step = (p.lvl - inner) / 6
    room = min(longer ? 16 : 11, barIndex + 1 - p.firstBar)
    tone = lightPanel ? ink(c) : c
    for j = 0 to 5 {
      part = arrayGet(p.sv, j) / most
      a = p.lvl - (j + 0.1) * step
      b = p.lvl - (j + 0.9) * step
      slotLen = halfUp(room * part)
      if slotLen >= 1 {
        shade = j == peak ? 0 : 65 - halfUp(45 * part)
        arrayPush(p.prof, boxNew(barIndex + 1 - slotLen, max(a, b), barIndex + 1, min(a, b), bgcolor = colorNew(j == peak ? edgeOf(c) : tone, shade), border_color = colorNew(c, 100), border_width = 0))
      }
    }
  }
}

// A label just after the last bar, pointing at the level. Short lines, so it
// fits in the space before the price axis.
fn putPill(p, txt, why, strong) {
  c = sideColor(p.side)
  bg = colorNew(ink(c), strong ? 0 : 35)
  fg = "#ffffff"
  if na(p.note) {
    p.note := labelNew(p.lvl, txt, barIndex + 1, color = bg, textcolor = fg, size = size.small, style = label.style_label_left, tooltip = why)
  } else {
    labelSetXy(p.note, barIndex + 1, p.lvl)
    labelSetText(p.note, txt)
    labelSetTooltip(p.note, why)
    labelSetColor(p.note, bg)
    labelSetTextcolor(p.note, fg)
  }
}

// ── Everything below runs only from the warm-up start on ──
if barIndex >= warmStart {
rangeAvg := atr(21)
volAvg := sma(volume, 50)
inWindow = barIndex >= windowStart
hiPoint = pivothigh(swingSize, swingSize)
loPoint = pivotlow(swingSize, swingSize)

// ── New swing points ──
if inWindow and not na(rangeAvg) {
  if not na(hiPoint) {
    addPoint(1, hiPoint, barIndex - swingSize, nz(volume[swingSize] / volAvg[swingSize], 1.0), max(open[swingSize], close[swingSize]))
  }
  if not na(loPoint) {
    addPoint(-1, loPoint, barIndex - swingSize, nz(volume[swingSize] / volAvg[swingSize], 1.0), min(open[swingSize], close[swingSize]))
  }
}

// ── Live levels: break, band volume and approach ──
if inWindow and arraySize(pockets) > 0 {
  i = arraySize(pockets) - 1
  while i >= 0 {
    p = arrayGet(pockets, i)
    takenOut = p.side == 1 ? high > p.lvl : low < p.lvl
    if takenOut and minTouches > p.hits {
      // A single unconfirmed point taken out: not a level yet, nothing to record.
      dropDrawing(p)
      arrayRemove(pockets, i)
    } else if takenOut {
      p.phase := 1
      p.sweepBar := barIndex
      p.streak := 0
      p.loudSweep := volume >= 1.5 * volAvg
      dropDots(p)
      dropLayers(p)
      if not na(p.ln) {
        // A broken zone steps back: the edge and histogram go, the zone stays as a
        // faint fill without a frame, from the level's first test to the break.
        c = sideColor(p.side)
        inner = innerOf(p)
        lineDelete(p.ln)
        p.ln := na
        p.band := boxNew(p.firstBar, max(p.lvl, inner), barIndex, min(p.lvl, inner), bgcolor = colorNew(c, lightPanel ? 91 : 93), border_color = colorNew(c, 100), border_width = 0)
      }
      arrayRemove(pockets, i)
      arrayPush(pastSweeps, p)
      alert("Price broke a " + tierWord(p.tier) + " " + sideName(p.side) + " at " + toString(p.lvl), alert.freq_once_per_bar)
    } else {
      // Volume that traded inside the band, split by where the bar closed in its range.
      edge = p.side == 1 ? p.lvl - tolerance * rangeAvg : p.lvl + tolerance * rangeAvg
      if (p.side == 1 and high >= edge) or (p.side == -1 and low <= edge) {
        p.bandVol := p.bandVol + volume
        p.buyVol := p.buyVol + (high > low ? volume * (close - low) / (high - low) : volume / 2)
      }
      // Every bar that reaches into the zone adds to its histogram.
      addSlices(p, high, low, volume)
      if p.hits >= minTouches and not p.warned and abs(close - p.lvl) <= approachAtr * rangeAvg {
        p.warned := true
        alert("Price is near a " + tierWord(p.tier) + " " + sideName(p.side) + " at " + toString(p.lvl), alert.freq_once_per_bar)
      }
    }
    i := i - 1
  }
}

// ── Broken levels: wait for a decision ──
if inWindow and arraySize(pastSweeps) > 0 {
  for i = 0 to arraySize(pastSweeps) - 1 {
    p = arrayGet(pastSweeps, i)
    if p.phase == 1 {
      inside = p.side == 1 ? close < p.lvl : close > p.lvl
      waited = barIndex - p.sweepBar
      if inside {
        p.verdict := 1
        alert("Failed break: price closed back " + (p.side == 1 ? "below the resistance" : "above the support") + " at " + toString(p.lvl), alert.freq_once_per_bar)
      } else {
        p.streak := p.streak + 1
        if p.streak >= acceptCloses {
          p.verdict := 2
          alert((p.side == 1 ? "Resistance broken: " : "Support broken: ") + toString(acceptCloses) + (acceptCloses == 1 ? " close " : " closes in a row ") + (p.side == 1 ? "above " : "below ") + toString(p.lvl), alert.freq_once_per_bar)
        } else if waited >= decisionBars {
          p.verdict := 3
        }
      }
      if p.verdict > 0 {
        p.phase := 2
        dropDots(p)
        record(p)
        if markOutcomes and p.verdict != 3 and keepSwept {
          mc = p.verdict == 1 ? colReclaim : colAccept
          what = p.verdict == 1 ? "Failed break: price closed back " + (p.side == 1 ? "below the resistance" : "above the support") : (p.side == 1 ? "Resistance broken: " : "Support broken: ") + toString(acceptCloses) + (acceptCloses == 1 ? " close " : " closes in a row ") + (p.side == 1 ? "above it" : "below it")
          when = waited == 0 ? "on the breaking bar" : toString(waited) + (waited == 1 ? " bar" : " bars") + " after the break"
          why = what + "\n" + (p.verdict == 1 ? "Failed " : "Confirmed ") + when + " · " + stamp() + " UTC\n" + tierName(p.tier) + " level · tested " + timesWord(p.hits) + "\n" + (p.loudSweep ? "High" : "Low") + " volume on the break"
          // A small arrow in the direction price went after the break, in a see-through box
          // (the chart shows the tooltip of boxed labels only); the details are in the tooltip.
          p.tag := labelNew(p.lvl, arrowOf(p), barIndex, color = colorNew(PAL_GROUND, 100), style = p.side == 1 ? label.style_label_down : label.style_label_up, size = tableSize == "Small" ? size.small : tableSize == "Large" ? size.large : size.normal, textcolor = ink(mc), tooltip = why)
        }
      }
    }
  }
}

// Hide or trim the history of broken levels: the latest `keptBroken` stay on the chart.
if inWindow and arraySize(pastSweeps) > 0 and (not keepSwept or arraySize(pastSweeps) > keptBroken) {
  limit = keepSwept ? keptBroken : 0
  trimming = true
  while trimming and arraySize(pastSweeps) > limit {
    old = arrayGet(pastSweeps, 0)
    if old.phase == 2 {
      dropDrawing(old)
      arrayShift(pastSweeps)
    } else {
      trimming := false
    }
  }
}

// ── Last bar: bands, labels and the table ──
if barstate.islast {
  // Where the table goes. The last 150 bars stand in for what is on screen.
  span = min(149, barIndex)
  hi = high
  lo = low
  for k = 1 to span {
    hi := max(hi, high[k])
    lo := min(lo, low[k])
  }
  // Auto keeps the table top right. It moves top left only when a labeled live
  // resistance (one of the two nearest) or the price reaches into the part of the
  // range the table covers there: the top 10% / 15% / 24% of the 150-bar range (small / medium / large
  // table; measured on a 1600 x 1000 window, where a medium table ends 14% below
  // the top of the range).
  guard = close
  if arraySize(pockets) > 0 {
    nearestUp = na
    secondUp = na
    for j = 0 to arraySize(pockets) - 1 {
      q = arrayGet(pockets, j)
      if q.side == 1 and q.hits >= minTouches {
        if na(nearestUp) or nearestUp > q.lvl {
          secondUp := nearestUp
          nearestUp := q.lvl
        } else if na(secondUp) or secondUp > q.lvl {
          secondUp := q.lvl
        }
      }
    }
    // The second one counts only with labels on and inside the 150-bar range (its label is then on screen).
    guard := na(nearestUp) ? close : max(close, showNotes and not na(secondUp) and secondUp <= hi ? secondUp : nearestUp)
  }
  share = tableSize == "Small" ? 0.1 : tableSize == "Large" ? 0.24 : 0.15
  tablePlace := tableCorner == "Auto" ? (guard >= hi - share * (hi - lo) ? "Top left" : "Top right") : tableCorner
  waiting = 0
  if arraySize(pastSweeps) > 0 {
    for j = 0 to arraySize(pastSweeps) - 1 {
      q = arrayGet(pastSweeps, j)
      // The newest broken zone is the clearest; each older one fades a step.
      if not na(q.band) {
        age = arraySize(pastSweeps) - 1 - j
        boxSetBgColor(q.band, colorNew(sideColor(q.side), min(97, (lightPanel ? 91 : 93) + age)))
      }
      if q.phase == 1 {
        waiting := waiting + 1
        if showNotes and not na(q.band) {
          left = decisionBars - (barIndex - q.sweepBar)
          ago = barIndex - q.sweepBar
          putPill(q, "Pending break\n" + toString(left) + (left == 1 ? " bar left" : " bars left"), "Price broke this level " + toString(ago) + (ago == 1 ? " bar ago." : " bars ago.") + "\nIt fails if price closes back inside within " + toString(decisionBars) + " bars;\n" + toString(acceptCloses) + (acceptCloses == 1 ? " close" : " closes in a row") + " beyond it confirm" + (acceptCloses == 1 ? "s" : "") + " it.", false)
        }
      }
    }
  }
  // The two nearest live levels on each side get a label.
  up1 = -1
  up2 = -1
  dn1 = -1
  dn2 = -1
  if arraySize(pockets) > 0 {
    for j = 0 to arraySize(pockets) - 1 {
      q = arrayGet(pockets, j)
      if q.hits >= minTouches {
        d = abs(q.lvl - close)
        if q.side == 1 {
          if up1 < 0 or d < abs(arrayGet(pockets, up1).lvl - close) {
            up2 := up1
            up1 := j
          } else if up2 < 0 or d < abs(arrayGet(pockets, up2).lvl - close) {
            up2 := j
          }
        } else {
          if dn1 < 0 or d < abs(arrayGet(pockets, dn1).lvl - close) {
            dn2 := dn1
            dn1 := j
          } else if dn2 < 0 or d < abs(arrayGet(pockets, dn2).lvl - close) {
            dn2 := j
          }
        }
      }
    }
    for j = 0 to arraySize(pockets) - 1 {
      q = arrayGet(pockets, j)
      if q.hits >= minTouches {
        paintBand(q, abs(close - q.lvl) <= approachAtr * rangeAvg, j == up1 or j == up2 or j == dn1 or j == dn2, j == up1 or j == dn1)
      }
      if showNotes and q.hits >= minTouches and (j == up1 or j == up2 or j == dn1 or j == dn2) {
        // Resistance shows the sell share, support the buy share.
        sideVol = q.side == 1 ? q.bandVol - q.buyVol : q.buyVol
        who = q.side == 1 ? " sell volume" : " buy volume"
        short = q.side == 1 ? " selling" : " buying"
        head = q.side == 1 ? "Resistance" : "Support"
        dist = oneDecimal(abs(q.lvl - close) / close * 100)
        why = tierName(q.tier) + " " + sideName(q.side) + " at " + toString(q.lvl) + " · tested " + timesWord(q.hits) + "\n" + dist + (q.side == 1 ? "% above price" : "% below price") + "\n" + (q.bandVol > 0 ? pct(sideVol, q.bandVol) + who + " here (estimated from where bars closed)" : "No volume traded here yet") + "\nVolume traded here: " + (volAvg > 0 ? oneDecimal(q.bandVol / volAvg) : "-") + "× an average bar"
        putPill(q, head + "\ntested " + timesWord(q.hits) + (q.bandVol > 0 ? "\n" + pct(sideVol, q.bandVol) + short : ""), why, abs(close - q.lvl) <= approachAtr * rangeAvg)
      } else if not na(q.note) {
        labelDelete(q.note)
        q.note := na
      }
    }
  }
  if showProfile {
    // Top right the table starts one empty row lower, clear of the line at the top
    // of the chart that shows the bar's prices. Top left the chart itself starts it
    // below the order buttons and the indicator names, however many there are.
    top = tablePlace == "Top right" ? 1 : 0
    // The table's own ground is clear, so the empty rows stay invisible where nothing
    // covers them (a shared chart picture has no buttons or indicator name on it).
    // The chart paints a cell's ground a little see-through, so every cell is drawn
    // twice: a ground-only copy underneath, then the text. Bars and bands under the
    // table do not show through the numbers.
    if na(board) {
      boardBack := tableNew(cornerOf(tablePlace), 4, 8 + top, bgcolor = colorNew(PAL_GROUND, 100), border_width = 0, frame_width = 0)
      board := tableNew(cornerOf(tablePlace), 4, 8 + top, bgcolor = colorNew(PAL_GROUND, 100), border_width = 0, frame_width = 0)
      if top > 0 {
        for k = 0 to top - 1 {
          tableCell(boardBack, 0, k, " ", text_size = size.normal)
          tableCell(board, 0, k, " ", text_size = size.normal)
        }
      }
    }
    ground = colorNew(PAL_GROUND, 0)
    for layer = 0 to 1 {
      tb = layer == 0 ? boardBack : board
      // The copy underneath keeps the text's size (so both line up) but not its color.
      see = layer == 0 ? 100 : 0
      tableCell(tb, 0, top, "Level breaks · last " + toString(lookback) + " bars" + (waiting > 0 ? " · " + toString(waiting) + " pending" : ""), text_color = colorNew(PAL_TEXT, see), text_size = txtSize, bgcolor = ground)
      tableMergeCells(tb, 0, top, 3, top)
      tableCell(tb, 0, top + 1, "", text_size = txtSize, bgcolor = ground)
      tableCell(tb, 1, top + 1, "Breaks", text_color = colorNew(PAL_MUTED, see), text_size = txtSize, bgcolor = ground, tooltip = layer == 0 ? "" : "How many times price broke a level and the outcome was decided.")
      tableCell(tb, 2, top + 1, "Failed", text_color = colorNew(PAL_MUTED, see), text_size = txtSize, bgcolor = ground, tooltip = layer == 0 ? "" : "Price closed back inside the level within " + toString(decisionBars) + " bars of the break.")
      tableCell(tb, 3, top + 1, "Confirmed", text_color = colorNew(PAL_MUTED, see), text_size = txtSize, bgcolor = ground, tooltip = layer == 0 ? "" : toString(acceptCloses) + (acceptCloses == 1 ? " close" : " closes in a row") + " beyond the level within " + toString(decisionBars) + " bars of the break.")
      total = 0
      totalReclaim = 0
      totalAccept = 0
      for k = 0 to 5 {
        n = k < 3 ? arrayGet(decided, k) : k == 3 ? total : k == 4 ? loudDone : quietDone
        r = k < 3 ? arrayGet(reclaims, k) : k == 3 ? totalReclaim : k == 4 ? loudReclaim : quietReclaim
        a = k < 3 ? arrayGet(accepts, k) : k == 3 ? totalAccept : k == 4 ? loudAccept : quietAccept
        enough = n >= minOutcomes
        name = k < 3 ? tierName(k + 1) + " levels" : k == 3 ? "All levels" : k == 4 ? "High-volume breaks" : "Low-volume breaks"
        tableCell(tb, 0, top + 2 + k, name, text_color = colorNew(k == 3 ? PAL_MUTED : PAL_TEXT, see), text_size = txtSize, bgcolor = ground)
        tableCell(tb, 1, top + 2 + k, toString(n), text_color = colorNew(PAL_TEXT, see), text_size = txtSize, bgcolor = ground)
        few = layer == 0 ? "" : "Fewer than " + toString(minOutcomes) + " breaks: too few for a percentage."
        tableCell(tb, 2, top + 2 + k, enough ? pct(r, n) : "—", text_color = colorNew(enough ? ink(colReclaim) : PAL_MUTED, see), text_size = txtSize, bgcolor = ground, tooltip = enough ? "" : few)
        tableCell(tb, 3, top + 2 + k, enough ? pct(a, n) : "—", text_color = colorNew(enough ? ink(colAccept) : PAL_MUTED, see), text_size = txtSize, bgcolor = ground, tooltip = enough ? "" : few)
        if k < 3 {
          total := total + n
          totalReclaim := totalReclaim + r
          totalAccept := totalAccept + a
        }
      }
    }
  }
}
}