Indicator · Pine Script v6 · On the chart

Broadening Wedge Scanner Pro [WavesUnchained]

by casoon

This indicator scans price action on the main chart to identify broadening wedge patterns, including descending, ascending, and symmetric formations.

View on GitHub

GitHub stars9on the repository
Pine versionv6current
Lines of code1,060
Settings29inputs
LicenceMIT
Last updateyesterday27 Oct 2026

Add It to Your TradingView Chart

  1. 1Copy the scriptPress Copy script above, or the copy button on the code below.
  2. 2Open the Pine EditorOpen a TradingView chart and choose Pine Editor at the bottom of the screen.
  3. 3Paste over the templateCreate a new indicator, select all of the template text and paste. Press Save and give it a name.
  4. 4Add it to the chartPress Add to chart, then adjust the settings with the gear icon next to its name.

Source Code

1,060 lines of Pine Script v6 from indicators/market_structure/broadening_wedge_scanner_pro/broadening_wedge_scanner_pro.pine, published under MIT. Keep the author's credit and licence lines when you reuse it.

broadening_wedge_scanner_pro.pine
//@version=6
// ============================================================================
// Broadening Wedge Scanner Pro [WavesUnchained]
// Version: 1.15
// Author: WavesUnchained
// Build: 2026-10-02 00:00:00
// ============================================================================
// Slides a fixed-size window of confirmed swings across the search depth and
// fits two boundary lines (upper from the window's outer highs, lower from
// its outer lows) to find EXPANDING wedges — descending broadening (both
// lines fall, lower faster — bullish bias), ascending broadening (both lines
// rise, upper faster — bearish bias), or symmetric broadening (upper rising,
// lower falling — direction-neutral "megaphone"). Every distinct,
// non-overlapping valid wedge is tracked independently until a confirmed
// breakout resolves it.
//
// Features:
//   ✓ Sliding-window search across consecutive, alternating swing windows
//   ✓ Tracks multiple simultaneously valid, non-overlapping wedges at once,
//     each with its own drawings and breakout state
//   ✓ Weighted 0-100 score: traversal, realized expansion, boundary
//     reliability, line quality, duration, and current relevance
//   ✓ Live breakout tracking (either direction resolves the pattern) + alert
//   ✓ Near-miss candidate marker and score-breakdown debug table
//
// ============================================================================

indicator("Broadening Wedge Scanner Pro [WavesUnchained]", overlay=true, max_lines_count=500, max_labels_count=500)

// ============================================================================
// Inputs
// ============================================================================

pivotLen              = input.int(6, "Swing-Stärke", minval=2, maxval=30)
maxSwings             = input.int(80, "Max. gespeicherte Swings", minval=20, maxval=200)
searchDepth           = input.int(24, "Suchbereich letzte Swings", minval=8, maxval=60)
wedgeWindow           = input.int(8, "Swings pro Keil-Fenster", minval=6, maxval=20)
maxConcurrentPatterns = input.int(1, "Max. gleichzeitige Patterns", minval=1, maxval=15)
maxPatternAgeMult     = input.float(1.5, "Max. Pattern-Alter (× Fensterbreite)", minval=1.0, step=0.5)

minScore           = input.int(60, "Mindestqualität", minval=0, maxval=100)
atrLen             = input.int(14, "ATR-Länge")
touchTolAtr        = input.float(0.5, "Touch-Toleranz ATR", minval=0.05, step=0.05)
projectedExpansionTarget = input.float(1.3, "Ziel proj. Endbreite / Startbreite", minval=1.05, maxval=3.0, step=0.05)
minWidthAtr        = input.float(1.0, "Mindest-Endbreite ATR", minval=0.2, step=0.1)
fasterRatioMin     = input.float(1.2, "Min. Geschwindigkeits-Verhältnis (DBW/ABW)", minval=1.0, maxval=3.0, step=0.05)
minTraversalRatio  = input.float(0.60, "Min. Traversal pro Leg", minval=0.40, maxval=0.90, step=0.05)
minRealizedExpansionRatio = input.float(1.15, "Min. realisierte Leg-Expansion", minval=1.0, maxval=3.0, step=0.05)
minAnchorSpanRatio = input.float(0.55, "Min. Anker-Span / Pattern-Dauer", minval=0.30, maxval=0.90, step=0.05)
maxBoundaryDriftAtr = input.float(8.0, "Max. Boundary-Drift ATR", minval=1.0, maxval=20.0, step=0.5)

showOldPatterns   = input.bool(false, "Aufgelöste Patterns behalten")
showCandidate     = input.bool(true, "Kandidaten anzeigen")
lineAheadBars     = input.int(10, "Linien-Überstand (Bars)", minval=1, maxval=50)
showLabels        = input.bool(true, "Score-Label anzeigen")

wedgeCol          = input.color(color.purple, "Keil (offen)")
bullCol           = input.color(color.lime, "Bullischer Breakout")
bearCol           = input.color(color.red, "Bärischer Breakout")
candidateCol      = input.color(color.new(color.orange, 0), "Kandidat")

groupDebug        = "Debug"
showDebug         = input.bool(false, "Score-Breakdown Debug-Tabelle", group=groupDebug)
candidateThreshold = input.int(25, "Kandidat-Mindestscore (Watch)", minval=0, maxval=100, group=groupDebug)
enableLogging     = input.bool(false, "Pine-Logs aktivieren (Fehlersuche)", group=groupDebug)

groupDash         = "Dashboard"
showDashboard     = input.bool(true, "Dashboard anzeigen", group=groupDash)

groupAlert        = "Alerts"
alertsOnBarClose  = input.bool(true, "Alerts nur bei bestätigter Kerze (Bar Close)", group=groupAlert)

atr = ta.atr(atrLen)

// The bar-by-bar containment check below indexes high/low up to
// searchDepth × pivotLen bars back, which can exceed Pine's default
// auto-detected historical buffer for a variable (non-constant) offset.
max_bars_back(high, 2000)
max_bars_back(low, 2000)
max_bars_back(close, 2000)

// ============================================================================
// Types
// ============================================================================

type Swing
    int x
    float y
    int dir      // +1 = High, -1 = Low

type Pattern
    bool valid
    string ttype          // "Descending Broadening" | "Ascending Broadening" | "Symmetric Broadening" | "None"
    int score
    int xU1
    int xU2
    float yU1
    float yU2
    int xL1
    int xL2
    float yL1
    float yL2
    int windowStartX
    int windowEndX
    bool structureOk
    bool pathOk
    bool traversalOk
    bool realizedExpansionOk
    bool boundaryOk
    bool lineQualityOk
    bool durationOk
    bool barsInsideOk
    bool boundariesOrderedOk
    bool widthOk
    bool anchorSpanOk
    bool driftOk
    float upperAnchorSpanRatio
    float lowerAnchorSpanRatio
    float upperDriftAtr
    float lowerDriftAtr
    float containmentRatio
    int progressionMatches
    int progressionTests
    float realizedExpansionRatio
    int legGrowthMatches
    int legGrowthTests
    float legGrowthRatio
    float projectedExpansionRatio
    int pathScore
    int traversalScore
    int rangeScore
    int touchScore
    int lineScore
    int anchorScore
    int durationScore
    int relevanceScore

// A tracked, currently-live pattern owns its own drawing objects so it can
// be cleaned up independently of every other tracked pattern.
type TrackedPattern
    Pattern p
    line[] objLines
    label[] objLabels
    int createdBar

// ============================================================================
// Storage
// ============================================================================

var swings = array.new<Swing>()
var candidateLines = array.new_line()
var candidateLabels = array.new_label()

// Tracks the newest swing's bar index, not the array LENGTH — once `swings`
// hits its `maxSwings` cap, every push is paired with a shift, so the length
// never changes again and a length-based "did a new swing form" check would
// freeze forever right when the cap is first reached.
var int lastSwingX = -1

emptyPattern() =>
    Pattern.new(false, "None", 0, na, na, na, na, na, na, na, na, na, na,
         false, false, false, false, false, false, false, false,
         false, false, false, false, na, na, na, na, na,
         0, 0, na, 0, 0, na, na,
         0, 0, 0, 0, 0, 0, 0, 0)

// Single best-in-window candidate, regardless of whether it could ever
// actually be tracked — kept only for the debug table (explains why the
// overall best-scoring shape still isn't firing) and the near-miss
// candidate marker. Independent of `trackedPatterns` below, which holds
// every distinct, non-overlapping VALID wedge found at once.
var Pattern best = emptyPattern()

var trackedPatterns = array.new<TrackedPattern>()

var table dash = table.new(position.top_right, 2, 5,
     bgcolor=color.new(color.white, 5),
     border_color=color.new(color.gray, 60),
     border_width=1,
     frame_color=color.new(color.gray, 40),
     frame_width=1)

var table dbgTable = table.new(position.bottom_right, 2, 15,
     bgcolor=color.new(color.white, 5),
     border_color=color.new(color.gray, 60),
     border_width=1,
     frame_color=color.new(color.gray, 40),
     frame_width=1)

color tc = color.new(color.gray, 20)
color hd = color.new(color.gray, 90)

// ============================================================================
// Helpers
// ============================================================================

f_clear_candidate() =>
    while array.size(candidateLines) > 0
        line.delete(array.pop(candidateLines))
    while array.size(candidateLabels) > 0
        label.delete(array.pop(candidateLabels))

f_log(string msg) =>
    if enableLogging
        log.info("BWS " + msg)

f_line(line[] arr, int x1, float y1, int x2, float y2, color col, style, int width, ext = extend.none) =>
    ln = line.new(x1, y1, x2, y2, color=col, style=style, width=width, extend=ext)
    array.push(arr, ln)
    ln

f_price(int x1, float y1, int x2, float y2, int x) =>
    x2 == x1 ? y1 : y1 + (y2 - y1) * float(x - x1) / float(x2 - x1)

f_slope(int x1, float y1, int x2, float y2) =>
    x2 == x1 ? na : (y2 - y1) / float(x2 - x1)

f_clamp01(float value) =>
    math.min(math.max(value, 0.0), 1.0)

// Picks the two REAL touch points (not a computed average) that best act as
// a boundary: the pair with the fewest other points violating it (poking
// through by more than `tolPrice` on the wrong side — above for an upper/
// resistance line, below for a lower/support one), tie-broken by the most
// points touching it within tolerance and then by the widest anchor span.
// This is how a chartist actually draws
// a trendline — through real highs/lows that define the edge — not a
// least-squares fit, which pulls the line through the middle of the point
// cloud and can miss every real touch when a few points cluster together.
// O(n^3) over the window's own point count (n = nH or nL, capped by half of
// `wedgeWindow`, so at most a few dozen), trivial at this scale. Requires at
// least 2 points; callers already guarantee this (`array.size(highX) >= 2
// and array.size(lowX) >= 2`).
f_best_boundary(int[] xs, float[] ys, float tolPrice, bool isUpper) =>
    n = array.size(xs)
    bestI = 0
    bestJ = n - 1
    bestViolations = 1000000
    bestTouches = -1
    bestSpan = -1
    for i = 0 to n - 2
        xi = array.get(xs, i)
        yi = array.get(ys, i)
        for j = i + 1 to n - 1
            xj = array.get(xs, j)
            yj = array.get(ys, j)
            touches = 0
            violations = 0
            for k = 0 to n - 1
                xk = array.get(xs, k)
                yk = array.get(ys, k)
                dist = yk - f_price(xi, yi, xj, yj, xk)
                if math.abs(dist) <= tolPrice
                    touches += 1
                else if isUpper and dist > tolPrice
                    violations += 1
                else if not isUpper and dist < -tolPrice
                    violations += 1
            span = xj - xi
            better = violations < bestViolations or (violations == bestViolations and (touches > bestTouches or (touches == bestTouches and span > bestSpan)))
            if better
                bestViolations := violations
                bestTouches := touches
                bestSpan := span
                bestI := i
                bestJ := j
    [array.get(xs, bestI), array.get(ys, bestI), array.get(xs, bestJ), array.get(ys, bestJ)]

f_add_swing(int x, float y, int dir) =>
    sz = array.size(swings)

    if sz == 0
        array.push(swings, Swing.new(x, y, dir))
    else
        last = array.get(swings, sz - 1)

        if last.dir == dir
            stronger = dir == 1 ? y > last.y : y < last.y
            if stronger
                array.set(swings, sz - 1, Swing.new(x, y, dir))
        else
            array.push(swings, Swing.new(x, y, dir))

    while array.size(swings) > maxSwings
        array.shift(swings)

// True interval overlap between two patterns' [windowStartX,windowEndX]
// ranges — keeps at most one tracked wedge per stretch of chart.
f_overlaps(Pattern a, Pattern b) =>
    math.max(a.windowStartX, b.windowStartX) <= math.min(a.windowEndX, b.windowEndX)

f_find_overlap_index(Pattern p) =>
    n = array.size(trackedPatterns)
    idx = -1
    if n > 0
        for i = 0 to n - 1
            tp = array.get(trackedPatterns, i)
            if idx < 0 and f_overlaps(p, tp.p)
                idx := i
    idx

// Index of the lowest-scoring currently tracked pattern — used to make room
// for a new, non-overlapping, higher-scoring candidate once the cap is
// reached. Without this, the first `maxConcurrentPatterns` wedges that
// happen not to break out for a long stretch permanently fill every slot,
// silently discarding every new, better candidate found afterward (the same
// bug fixed in Wolfe Wave Scanner Pro's f_find_weakest_index).
f_find_weakest_index() =>
    n = array.size(trackedPatterns)
    idx = -1
    minScoreSeen = 1000000
    if n > 0
        for i = 0 to n - 1
            tp = array.get(trackedPatterns, i)
            if tp.p.score < minScoreSeen
                minScoreSeen := tp.p.score
                idx := i
    idx

// Index of the highest-scoring currently tracked pattern — used so the
// dashboard reflects an actual tracked, live pattern instead of the raw
// search candidate (same fix as Wolfe Wave Scanner Pro's
// f_find_best_active_index).
f_find_best_active_index() =>
    n = array.size(trackedPatterns)
    idx = -1
    maxScoreSeen = -1
    if n > 0
        for i = 0 to n - 1
            tp = array.get(trackedPatterns, i)
            if tp.p.score > maxScoreSeen
                maxScoreSeen := tp.p.score
                idx := i
    idx

// Deletes every drawing object owned by a tracked pattern. Arrays are
// reference types in Pine, so popping from tp.objLines/objLabels mutates
// the exact arrays the TrackedPattern struct already holds.
f_delete_tracked(TrackedPattern tp) =>
    while array.size(tp.objLines) > 0
        line.delete(array.pop(tp.objLines))
    while array.size(tp.objLabels) > 0
        label.delete(array.pop(tp.objLabels))

// Finalizes a still-open tracked pattern's boundary lines into a bounded
// segment ending at the current bar, instead of leaving them on
// extend.right forever. Needed whenever a pattern is displaced or resolved
// while `showOldPatterns` is on and its lines are kept on the chart — without
// this, every kept wedge boundary drifts as an infinite ray extrapolated off
// a 2-point fit, landing at nonsensical prices the longer the chart runs.
f_finalize_lines(TrackedPattern tp) =>
    if array.size(tp.objLines) >= 2
        upperLine = array.get(tp.objLines, 0)
        lowerLine = array.get(tp.objLines, 1)
        upperNow = f_price(tp.p.xU1, tp.p.yU1, tp.p.xU2, tp.p.yU2, bar_index)
        lowerNow = f_price(tp.p.xL1, tp.p.yL1, tp.p.xL2, tp.p.yL2, bar_index)
        line.set_extend(upperLine, extend.none)
        line.set_extend(lowerLine, extend.none)
        line.set_xy2(upperLine, bar_index, upperNow)
        line.set_xy2(lowerLine, bar_index, lowerNow)

// Cleans up same-direction overlap chains the pairwise insertion check can
// miss: if A overlaps B and B overlaps C, but A and C don't directly overlap
// each other, all three could end up tracked at once even though they
// visually occupy the same stretch (same fix as Wolfe Wave Scanner Pro's
// f_dedupe_overlaps). Checks every pair, keeping only the higher-scoring one.
f_dedupe_overlaps() =>
    n = array.size(trackedPatterns)
    if n > 1
        keep = array.new_bool(n, true)
        for i = 0 to n - 2
            for j = i + 1 to n - 1
                tpI = array.get(trackedPatterns, i)
                tpJ = array.get(trackedPatterns, j)
                if array.get(keep, i) and array.get(keep, j) and f_overlaps(tpI.p, tpJ.p)
                    if tpI.p.score >= tpJ.p.score
                        array.set(keep, j, false)
                    else
                        array.set(keep, i, false)
        for i = n - 1 to 0
            if not array.get(keep, i)
                tp = array.get(trackedPatterns, i)
                if showOldPatterns
                    f_finalize_lines(tp)
                else
                    f_delete_tracked(tp)
                array.remove(trackedPatterns, i)

// ============================================================================
// Scoring Engine
// ============================================================================

f_eval_wedge(int[] pathX, float[] pathY, int[] pathDir, int[] highX, float[] highY, int[] lowX, float[] lowY, int windowStartX, int windowEndX) =>
    nH = array.size(highX)
    nL = array.size(lowX)
    nPath = array.size(pathX)

    touchTolPrice = touchTolAtr * atr

    // Boundaries are the two real touch points that best act as a resistance
    // (upper) / support (lower) line — fewest other points poking through it,
    // most points actually touching it — not a least-squares average through
    // the whole point cloud.
    [xU1, yU1, xU2, yU2] = f_best_boundary(highX, highY, touchTolPrice, true)
    [xL1, yL1, xL2, yL2] = f_best_boundary(lowX, lowY, touchTolPrice, false)

    slopeU = f_slope(xU1, yU1, xU2, yU2)
    slopeL = f_slope(xL1, yL1, xL2, yL2)

    bothFalling = not na(slopeU) and not na(slopeL) and slopeU < 0 and slopeL < 0
    bothRising  = not na(slopeU) and not na(slopeL) and slopeU > 0 and slopeL > 0
    diverging   = not na(slopeU) and not na(slopeL) and slopeU > 0 and slopeL < 0

    // Geometric family classification remains necessary, but is no longer
    // sufficient. The swing path below must independently confirm the same
    // higher-high/lower-low progression.
    isDescendingBroadening = bothFalling and math.abs(slopeL) > math.abs(slopeU)
    isAscendingBroadening  = bothRising and math.abs(slopeU) > math.abs(slopeL)
    isSymmetricBroadening  = diverging

    ttype = isDescendingBroadening ? "Descending Broadening" : isAscendingBroadening ? "Ascending Broadening" : isSymmetricBroadening ? "Symmetric Broadening" : "None"
    structureOk = isDescendingBroadening or isAscendingBroadening or isSymmetricBroadening

    midX = int(math.round((windowStartX + windowEndX) / 2.0))

    upperAtStart = f_price(xU1, yU1, xU2, yU2, windowStartX)
    lowerAtStart = f_price(xL1, yL1, xL2, yL2, windowStartX)
    upperAtMid   = f_price(xU1, yU1, xU2, yU2, midX)
    lowerAtMid   = f_price(xL1, yL1, xL2, yL2, midX)
    upperAtEnd   = f_price(xU1, yU1, xU2, yU2, windowEndX)
    lowerAtEnd   = f_price(xL1, yL1, xL2, yL2, windowEndX)

    widthStart = math.abs(upperAtStart - lowerAtStart)
    widthMid   = math.abs(upperAtMid - lowerAtMid)
    widthEnd   = math.abs(upperAtEnd - lowerAtEnd)

    boundariesOrdered = upperAtStart > lowerAtStart and upperAtMid > lowerAtMid and upperAtEnd > lowerAtEnd
    projectedExpansionRatio = widthStart > 0 ? widthEnd / widthStart : 0.0

    // The type-specific extremum sequence is a hard gate. Because `swings`
    // is already de-duplicated to alternating highs/lows, the remaining
    // question is whether every same-side step progresses in the direction
    // required by the classified broadening family.
    progressionTests = 0
    progressionMatches = 0
    alternatingPath = true
    if nPath > 1
        for i = 1 to nPath - 1
            if array.get(pathDir, i) == array.get(pathDir, i - 1)
                alternatingPath := false
    if nH > 1
        for i = 1 to nH - 1
            previous = array.get(highY, i - 1)
            current = array.get(highY, i)
            expectedUp = isAscendingBroadening or isSymmetricBroadening
            progressionTests += 1
            if (expectedUp and current > previous) or (not expectedUp and current < previous)
                progressionMatches += 1
    if nL > 1
        for i = 1 to nL - 1
            previous = array.get(lowY, i - 1)
            current = array.get(lowY, i)
            expectedDown = isDescendingBroadening or isSymmetricBroadening
            progressionTests += 1
            if (expectedDown and current < previous) or (not expectedDown and current > previous)
                progressionMatches += 1
    progressionRatio = progressionTests > 0 ? float(progressionMatches) / float(progressionTests) : 0.0
    pathOk = structureOk and alternatingPath and nPath >= 6 and nH >= 3 and nL >= 3 and progressionMatches == progressionTests
    pathScore = structureOk and alternatingPath ? int(math.round(20.0 * progressionRatio)) : 0

    // Measure each alternating leg as a fraction of the local channel width.
    // A large extrapolated envelope with price trapped around its midpoint
    // therefore scores poorly even when every bar technically remains inside.
    traversalSum = 0.0
    traversalCount = 0
    qualifyingTraversals = 0
    currentTraversalRun = 0
    longestTraversalRun = 0
    legAmplitudes = array.new_float()
    if nPath > 1
        for i = 1 to nPath - 1
            x1 = array.get(pathX, i - 1)
            y1 = array.get(pathY, i - 1)
            x2 = array.get(pathX, i)
            y2 = array.get(pathY, i)
            upper1 = f_price(xU1, yU1, xU2, yU2, x1)
            lower1 = f_price(xL1, yL1, xL2, yL2, x1)
            upper2 = f_price(xU1, yU1, xU2, yU2, x2)
            lower2 = f_price(xL1, yL1, xL2, yL2, x2)
            width1 = upper1 - lower1
            width2 = upper2 - lower2
            pos1 = width1 > 0 ? (y1 - lower1) / width1 : 0.5
            pos2 = width2 > 0 ? (y2 - lower2) / width2 : 0.5
            traversal = f_clamp01(math.abs(pos2 - pos1))
            traversalSum += traversal
            traversalCount += 1
            array.push(legAmplitudes, math.abs(y2 - y1))
            if traversal >= minTraversalRatio
                qualifyingTraversals += 1
                currentTraversalRun += 1
                longestTraversalRun := math.max(longestTraversalRun, currentTraversalRun)
            else
                currentTraversalRun := 0
    averageTraversal = traversalCount > 0 ? traversalSum / float(traversalCount) : 0.0
    qualifyingTraversalRatio = traversalCount > 0 ? float(qualifyingTraversals) / float(traversalCount) : 0.0
    traversalOk = longestTraversalRun >= 3
    traversalScore = int(math.round(20.0 * (0.65 * f_clamp01(averageTraversal / minTraversalRatio) + 0.35 * qualifyingTraversalRatio)))

    // Realized expansion comes from actual alternating swing legs, not from
    // the projected distance between two lines. Compare the first and last
    // two legs and require most same-direction leg amplitudes to grow. The
    // direction-aware comparison avoids penalizing the natural impulse/
    // retracement size alternation in ascending and descending patterns.
    legCount = array.size(legAmplitudes)
    firstLegAverage = legCount >= 2 ? (array.get(legAmplitudes, 0) + array.get(legAmplitudes, 1)) / 2.0 : 0.0
    lastLegAverage = legCount >= 2 ? (array.get(legAmplitudes, legCount - 2) + array.get(legAmplitudes, legCount - 1)) / 2.0 : 0.0
    realizedExpansionRatio = firstLegAverage > 0 ? lastLegAverage / firstLegAverage : 0.0
    legGrowthTests = 0
    legGrowthMatches = 0
    float previousUpLeg = na
    float previousDownLeg = na
    if legCount > 0
        for i = 0 to legCount - 1
            legAmplitude = array.get(legAmplitudes, i)
            legEndsAtHigh = array.get(pathDir, i + 1) == 1
            previousSameDirection = legEndsAtHigh ? previousUpLeg : previousDownLeg
            if not na(previousSameDirection)
                legGrowthTests += 1
                if legAmplitude >= previousSameDirection * 0.95
                    legGrowthMatches += 1
            if legEndsAtHigh
                previousUpLeg := legAmplitude
            else
                previousDownLeg := legAmplitude
    legGrowthRatio = legGrowthTests > 0 ? float(legGrowthMatches) / float(legGrowthTests) : 0.0
    realizedExpansionOk = legCount >= 5 and realizedExpansionRatio >= minRealizedExpansionRatio and legGrowthRatio >= 0.60
    realizedRatioQuality = minRealizedExpansionRatio > 1.0 ? f_clamp01((realizedExpansionRatio - 1.0) / (minRealizedExpansionRatio - 1.0)) : f_clamp01(realizedExpansionRatio)
    projectedRatioQuality = f_clamp01((projectedExpansionRatio - 1.0) / (projectedExpansionTarget - 1.0))
    rangeScore = int(math.round(15.0 * (0.70 * realizedRatioQuality + 0.20 * legGrowthRatio + 0.10 * projectedRatioQuality)))

    // Both defining pairs must span most of the pattern. A tie in boundary
    // selection already prefers the widest-spaced anchor pair, preventing a
    // close pair from winning and then being extrapolated across the window.
    windowBars = windowEndX - windowStartX
    upperAnchorSpanRatio = windowBars > 0 ? float(xU2 - xU1) / float(windowBars) : 0.0
    lowerAnchorSpanRatio = windowBars > 0 ? float(xL2 - xL1) / float(windowBars) : 0.0
    upperDriftAtr = atr > 0 ? math.abs(slopeU) * float(windowBars) / atr : 1000000.0
    lowerDriftAtr = atr > 0 ? math.abs(slopeL) * float(windowBars) / atr : 1000000.0
    widthOk = widthEnd > minWidthAtr * atr
    anchorSpanOk = upperAnchorSpanRatio >= minAnchorSpanRatio and lowerAnchorSpanRatio >= minAnchorSpanRatio
    driftOk = upperDriftAtr <= maxBoundaryDriftAtr and lowerDriftAtr <= maxBoundaryDriftAtr
    boundaryOk = boundariesOrdered and widthOk and anchorSpanOk and driftOk
    anchorScore = int(math.round(10.0 * (f_clamp01(upperAnchorSpanRatio) + f_clamp01(lowerAnchorSpanRatio)) / 2.0))

    // The two defining anchors are necessary, but do not earn touch-quality
    // points. Only independent confirmations beyond those anchors contribute.
    extraHighTouches = 0
    highResidualSum = 0.0
    highViolations = 0
    if nH > 0
        for i = 0 to nH - 1
            hx = array.get(highX, i)
            hy = array.get(highY, i)
            lineY = f_price(xU1, yU1, xU2, yU2, hx)
            dist = hy - lineY
            highResidualSum += math.abs(dist)
            if math.abs(hy - lineY) <= touchTolPrice
                if hx != xU1 and hx != xU2
                    extraHighTouches += 1
            else if dist > touchTolPrice
                highViolations += 1
    extraLowTouches = 0
    lowResidualSum = 0.0
    lowViolations = 0
    if nL > 0
        for i = 0 to nL - 1
            lx = array.get(lowX, i)
            ly = array.get(lowY, i)
            lineY = f_price(xL1, yL1, xL2, yL2, lx)
            dist = ly - lineY
            lowResidualSum += math.abs(dist)
            if math.abs(ly - lineY) <= touchTolPrice
                if lx != xL1 and lx != xL2
                    extraLowTouches += 1
            else if dist < -touchTolPrice
                lowViolations += 1
    extraHighQuality = f_clamp01(float(extraHighTouches) / 2.0)
    extraLowQuality = f_clamp01(float(extraLowTouches) / 2.0)
    touchScore = int(math.round(15.0 * (extraHighQuality + extraLowQuality) / 2.0))

    // DBW/ABW: the faster side must be meaningfully faster (not just
    // technically faster by a hair) to look like a real diverging wedge.
    // Symmetric broadening: neither side should be wildly steeper than the
    // other, same 3x-ratio reasoning as Triangle/Wolfe's symmetric case.
    fasterRatio = isDescendingBroadening and math.abs(slopeU) > 0.0000001 ? math.abs(slopeL) / math.abs(slopeU) : isAscendingBroadening and math.abs(slopeL) > 0.0000001 ? math.abs(slopeU) / math.abs(slopeL) : na
    symRatio = isSymmetricBroadening and math.abs(slopeL) > 0.0000001 ? math.abs(slopeU) / math.abs(slopeL) : na

    slopeQualityOk = (isDescendingBroadening or isAscendingBroadening) ? (not na(fasterRatio) and fasterRatio >= fasterRatioMin) : isSymmetricBroadening ? (not na(symRatio) and symRatio >= 0.3 and symRatio <= 3.0) : false
    durationOk = windowBars >= pivotLen * 3
    durationScore = int(math.round(5.0 * f_clamp01(float(windowBars) / float(pivotLen * 6))))

    // Containment is intentionally soft: count inside bars and middle-zone
    // occupancy instead of allowing an oversized envelope to pass a binary
    // veto. Traversal supplies the complementary boundary-utilization test.
    barsInsideCount = 0
    midZoneCount = 0
    evaluatedBars = 0
    for i = windowStartX to windowEndX
        upperAt = f_price(xU1, yU1, xU2, yU2, i)
        lowerAt = f_price(xL1, yL1, xL2, yL2, i)
        barHigh = high[bar_index - i]
        barLow = low[bar_index - i]
        barClose = close[bar_index - i]
        evaluatedBars += 1
        if barHigh <= upperAt + touchTolPrice and barLow >= lowerAt - touchTolPrice
            barsInsideCount += 1
        localWidth = upperAt - lowerAt
        if localWidth > 0
            closePosition = (barClose - lowerAt) / localWidth
            if closePosition >= 0.30 and closePosition <= 0.70
                midZoneCount += 1
    containmentRatio = evaluatedBars > 0 ? float(barsInsideCount) / float(evaluatedBars) : 0.0
    midZoneRatio = evaluatedBars > 0 ? float(midZoneCount) / float(evaluatedBars) : 1.0
    barsInsideOk = containmentRatio >= 0.90

    pointCount = nH + nL
    meanResidual = pointCount > 0 ? (highResidualSum + lowResidualSum) / float(pointCount) : touchTolPrice * 2.0
    residualQuality = touchTolPrice > 0 ? 1.0 - f_clamp01(meanResidual / (touchTolPrice * 2.0)) : 0.0
    violationRatio = pointCount > 0 ? float(highViolations + lowViolations) / float(pointCount) : 1.0
    lineScore = int(math.round(10.0 * (0.40 * residualQuality + 0.35 * containmentRatio + 0.15 * (1.0 - violationRatio) + 0.10 * (1.0 - midZoneRatio))))
    lineQualityOk = slopeQualityOk

    maxRelevantAge = maxPatternAgeMult * float(math.max(windowBars, 1))
    candidateAge = float(math.max(bar_index - windowEndX, 0))
    relevanceScore = int(math.round(5.0 * (maxRelevantAge > 0 ? 1.0 - f_clamp01(candidateAge / maxRelevantAge) : 0.0)))

    // The swing-path component is constant (20) for every pattern that passes
    // the pathOk gate, so it is excluded from the score: it stays a gate and a
    // debug readout only. The remaining components (80 points) are scaled to
    // 0-100 so the score range and Mindestqualität keep their meaning.
    rawScore = traversalScore + rangeScore + touchScore + lineScore + anchorScore + durationScore + relevanceScore
    score = int(math.round(float(rawScore) * 100.0 / 80.0))
    hardGatesOk = structureOk and pathOk and traversalOk and realizedExpansionOk and boundaryOk and lineQualityOk and durationOk

    Pattern.new(
         score >= minScore and hardGatesOk,
         ttype,
         score,
         xU1, xU2, yU1, yU2,
         xL1, xL2, yL1, yL2,
         windowStartX, windowEndX,
         structureOk, pathOk, traversalOk, realizedExpansionOk, boundaryOk, lineQualityOk, durationOk, barsInsideOk,
         boundariesOrdered, widthOk, anchorSpanOk, driftOk,
         upperAnchorSpanRatio, lowerAnchorSpanRatio, upperDriftAtr, lowerDriftAtr, containmentRatio,
         progressionMatches, progressionTests, realizedExpansionRatio,
         legGrowthMatches, legGrowthTests, legGrowthRatio, projectedExpansionRatio,
         pathScore, traversalScore, rangeScore, touchScore, lineScore, anchorScore, durationScore, relevanceScore)

// ============================================================================
// Pattern drawing — builds one self-contained TrackedPattern
// ============================================================================

// A pattern's boundary is a straight line fitted on its own window; extending
// it forever as the chart advances means extrapolating that fixed slope over
// an ever-growing distance, which inevitably drifts into nonsensical prices
// the longer a pattern stays untouched (chart- and log-confirmed: with no
// cap, ~100% of tracked wedges ended via ATR-drift, essentially never via a
// real breakout). Capping the extension to one more window-width past the
// window's own end bounds how far the line can ever be extrapolated, by
// construction — instead of drawing it forever and then reactively expiring
// it once the damage is already visible.
f_line_cap_x(Pattern p) =>
    p.windowEndX + (p.windowEndX - p.windowStartX)

f_draw_pattern(Pattern p) =>
    objLines = array.new_line()
    objLabels = array.new_label()

    // Ends a little past the current bar instead of extend.right, which would
    // draw the boundary all the way to the right edge of the chart. Advanced
    // bar by bar in the breakout-tracking loop below while the pattern stays
    // open (and short of the fixed cap), so it keeps pace with the chart
    // without ever spanning it or extrapolating indefinitely.
    aheadX = math.min(bar_index + lineAheadBars, f_line_cap_x(p))
    f_line(objLines, p.xU1, p.yU1, aheadX, f_price(p.xU1, p.yU1, p.xU2, p.yU2, aheadX), wedgeCol, line.style_solid, 2)
    f_line(objLines, p.xL1, p.yL1, aheadX, f_price(p.xL1, p.yL1, p.xL2, p.yL2, aheadX), wedgeCol, line.style_solid, 2)

    if showLabels
        txt = p.ttype + "\nScore " + str.tostring(p.score)
        lb = label.new(p.windowEndX, p.yU2, txt, style=label.style_label_down, color=wedgeCol, textcolor=color.white, size=size.small)
        array.push(objLabels, lb)

    TrackedPattern.new(p, objLines, objLabels, bar_index)

// ============================================================================
// Swing Detection
// ============================================================================

ph = ta.pivothigh(high, pivotLen, pivotLen)
pl = ta.pivotlow(low, pivotLen, pivotLen)

if not na(ph)
    f_add_swing(bar_index - pivotLen, ph, 1)
    f_log("SWING dir=H x=" + str.tostring(bar_index - pivotLen) + " y=" + str.tostring(ph, format.mintick) + " count=" + str.tostring(array.size(swings)))

if not na(pl)
    f_add_swing(bar_index - pivotLen, pl, -1)
    f_log("SWING dir=L x=" + str.tostring(bar_index - pivotLen) + " y=" + str.tostring(pl, format.mintick) + " count=" + str.tostring(array.size(swings)))

// ============================================================================
// Search (only re-run when a new swing has actually formed)
// ============================================================================
// Slides a fixed-size window across the search depth; for each window end,
// the outer highs/lows within it define the two boundary lines. Every
// window end contributes one fixed-size candidate, so two genuinely separate
// wedges in the search depth can both surface.

sz = array.size(swings)
currentSwingX = sz > 0 ? array.get(swings, sz - 1).x : -1
newSwing = currentSwingX != lastSwingX
lastSwingX := currentSwingX

newWedgeFound = false

if newSwing and sz < wedgeWindow
    f_log("SEARCH SKIPPED sz=" + str.tostring(sz) + " (<" + str.tostring(wedgeWindow) + ")")

if newSwing and sz >= wedgeWindow
    start = math.max(0, sz - searchDepth)
    candidatesPerWindowEnd = array.new<Pattern>()
    localBest = emptyPattern()

    // Guarded explicitly: Pine's `for i = A to B` silently reverses (and can
    // step on an out-of-bounds index) whenever A > B — which happens here if
    // `searchDepth` is set smaller than `wedgeWindow`, leaving no room for a
    // single full window inside the search range.
    firstEnd = math.max(start + wedgeWindow - 1, wedgeWindow - 1)
    if firstEnd <= sz - 1
        for end = firstEnd to sz - 1
            winStart = end - wedgeWindow + 1
            highX = array.new_int()
            highY = array.new_float()
            lowX  = array.new_int()
            lowY  = array.new_float()
            pathX = array.new_int()
            pathY = array.new_float()
            pathDir = array.new_int()

            for k = winStart to end
                s = array.get(swings, k)
                array.push(pathX, s.x)
                array.push(pathY, s.y)
                array.push(pathDir, s.dir)
                if s.dir == 1
                    array.push(highX, s.x)
                    array.push(highY, s.y)
                else
                    array.push(lowX, s.x)
                    array.push(lowY, s.y)

            candidate = emptyPattern()
            if array.size(highX) >= 2 and array.size(lowX) >= 2
                windowStartX = array.get(swings, winStart).x
                windowEndX = array.get(swings, end).x
                candidate := f_eval_wedge(pathX, pathY, pathDir, highX, highY, lowX, lowY, windowStartX, windowEndX)

            if candidate.score > localBest.score
                localBest := candidate

            array.push(candidatesPerWindowEnd, candidate)

    best := localBest
    f_log("SEARCH sz=" + str.tostring(sz) + " best.score=" + str.tostring(best.score) + " type=" + best.ttype + " valid=" + str.tostring(best.valid)
         + " structure=" + str.tostring(best.structureOk) + " path=" + str.tostring(best.pathOk)
         + " progression=" + str.tostring(best.progressionMatches) + "/" + str.tostring(best.progressionTests)
         + " traversal=" + str.tostring(best.traversalOk) + " realizedExpansion=" + str.tostring(best.realizedExpansionOk)
         + " realizedRatio=" + str.tostring(best.realizedExpansionRatio, "#.###")
         + " legGrowth=" + str.tostring(best.legGrowthMatches) + "/" + str.tostring(best.legGrowthTests)
         + " projectedRatio=" + str.tostring(best.projectedExpansionRatio, "#.###")
         + " boundary=" + str.tostring(best.boundaryOk) + " ordered=" + str.tostring(best.boundariesOrderedOk)
         + " width=" + str.tostring(best.widthOk) + " anchorSpan=" + str.tostring(best.anchorSpanOk)
         + " anchorU=" + str.tostring(best.upperAnchorSpanRatio, "#.###") + " anchorL=" + str.tostring(best.lowerAnchorSpanRatio, "#.###")
         + " drift=" + str.tostring(best.driftOk) + " driftU=" + str.tostring(best.upperDriftAtr, "#.##")
         + " driftL=" + str.tostring(best.lowerDriftAtr, "#.##")
         + " lineQuality=" + str.tostring(best.lineQualityOk) + " duration=" + str.tostring(best.durationOk))

    // Lightweight candidate wedge for the current best-in-window, redrawn on
    // every search regardless of whether it clears minScore.
    f_clear_candidate()
    if showCandidate and best.structureOk and best.score >= candidateThreshold and not best.valid
        candidateEndX = math.min(bar_index, f_line_cap_x(best))
        candidateUpperStart = f_price(best.xU1, best.yU1, best.xU2, best.yU2, best.windowStartX)
        candidateUpperEnd = f_price(best.xU1, best.yU1, best.xU2, best.yU2, candidateEndX)
        candidateLowerStart = f_price(best.xL1, best.yL1, best.xL2, best.yL2, best.windowStartX)
        candidateLowerEnd = f_price(best.xL1, best.yL1, best.xL2, best.yL2, candidateEndX)
        candidateReason = not best.pathOk ? "Swing-Pfad" : not best.traversalOk ? "Traversal" : not best.realizedExpansionOk ? "Realisierte Expansion" : not best.anchorSpanOk ? "Anker-Span" : not best.driftOk ? "Boundary-Drift" : not best.boundariesOrderedOk ? "Linienordnung" : not best.widthOk ? "Mindestbreite" : not best.lineQualityOk ? "Linienqualität" : not best.durationOk ? "Dauer" : "Hard Gate"
        candLn1 = line.new(best.windowStartX, candidateUpperStart, candidateEndX, candidateUpperEnd, color=candidateCol, style=line.style_dashed, width=2)
        candLn2 = line.new(best.windowStartX, candidateLowerStart, candidateEndX, candidateLowerEnd, color=candidateCol, style=line.style_dashed, width=2)
        array.push(candidateLines, candLn1)
        array.push(candidateLines, candLn2)
        candidateLabelY = math.min(math.max(close, math.min(candidateLowerEnd, candidateUpperEnd)), math.max(candidateLowerEnd, candidateUpperEnd))
        candLabel = label.new(candidateEndX, candidateLabelY, "WATCH · " + best.ttype + " · " + str.tostring(best.score) + "\nBlocker: " + candidateReason, style=label.style_label_down, color=candidateCol, textcolor=color.white, size=size.small)
        array.push(candidateLabels, candLabel)

    // Every distinct, non-overlapping valid wedge gets its own tracked
    // pattern. An overlapping candidate only replaces the existing one at
    // that spot if it scores higher and isn't literally the same window.
    if array.size(candidatesPerWindowEnd) > 0
        for i = 0 to array.size(candidatesPerWindowEnd) - 1
            c = array.get(candidatesPerWindowEnd, i)
            if c.valid
                // The newest swing needs pivotLen bars of opposite-direction
                // movement to even confirm — and that's often the same move
                // that already carries price through a boundary that, by
                // the candidate's own end width, was already tight at
                // windowEndX. Log-confirmed: 84% of all FIRED/REPLACED/EVICTED
                // events had a BREAKOUT logged on the exact same bar — without
                // this check, most detected wedges are stillborn: already
                // broken out the instant they're found, never visibly "live".
                // Same frozen-at-cap evaluation as the tracking loop: a
                // window-end deep in `Suchbereich letzte Swings` can be old
                // enough that extrapolating all the way to today's bar_index
                // already overshoots its own capped extension — evaluate at
                // the same bounded x so a merely-old-but-still-plausible
                // candidate isn't rejected as "stillborn" for a reason that
                // has nothing to do with whether it actually broke out.
                stillOpenEvalX = math.min(bar_index, f_line_cap_x(c))
                upperAtNow = f_price(c.xU1, c.yU1, c.xU2, c.yU2, stillOpenEvalX)
                lowerAtNow = f_price(c.xL1, c.yL1, c.xL2, c.yL2, stillOpenEvalX)
                stillOpen = close < upperAtNow and close > lowerAtNow
                // `Suchbereich letzte Swings` can resurface a window-end that's
                // already past its own age-expiry threshold by the time it's
                // found (log-confirmed: fired, then expired on the very same
                // bar with age=220 against an 81-bar window, 1.5×=121.5) —
                // gate it here instead of firing it just to immediately expire
                // it, which wastes a cycle and leaves the one tracking slot
                // empty for a whole search event over a candidate that was
                // never going to live anyway.
                alreadyAged = bar_index - c.windowEndX > maxPatternAgeMult * math.max(c.windowEndX - c.windowStartX, 1)
                if alreadyAged
                    f_log("PATTERN SKIPPED (already aged) type=" + c.ttype + " score=" + str.tostring(c.score) + " age=" + str.tostring(bar_index - c.windowEndX))
                else if not stillOpen
                    f_log("PATTERN SKIPPED (stillborn) type=" + c.ttype + " score=" + str.tostring(c.score))
                if stillOpen and not alreadyAged
                    overlapIdx = f_find_overlap_index(c)
                    if overlapIdx < 0
                        if array.size(trackedPatterns) < maxConcurrentPatterns
                            newTp = f_draw_pattern(c)
                            array.push(trackedPatterns, newTp)
                            newWedgeFound := true
                            f_log("PATTERN FIRED type=" + c.ttype + " score=" + str.tostring(c.score)
                                 + " start=" + str.tostring(c.windowStartX) + " end=" + str.tostring(c.windowEndX))
                        else
                            // Once every slot is full, a genuinely new, non-
                            // overlapping candidate must be able to evict the
                            // weakest tracked wedge if it outscores it — otherwise
                            // the first `maxConcurrentPatterns` wedges that happen
                            // not to break out for a while permanently occupy every
                            // slot and silently block every better candidate found
                            // afterward.
                            weakestIdx = f_find_weakest_index()
                            weakest = array.get(trackedPatterns, weakestIdx)
                            if c.score > weakest.p.score
                                if showOldPatterns
                                    f_finalize_lines(weakest)
                                else
                                    f_delete_tracked(weakest)
                                newTp = f_draw_pattern(c)
                                array.set(trackedPatterns, weakestIdx, newTp)
                                newWedgeFound := true
                                f_log("PATTERN EVICTED old_score=" + str.tostring(weakest.p.score) + " type=" + c.ttype + " score=" + str.tostring(c.score))
                    else
                        existing = array.get(trackedPatterns, overlapIdx)
                        if c.windowEndX != existing.p.windowEndX and c.score > existing.p.score
                            if showOldPatterns
                                f_finalize_lines(existing)
                            else
                                f_delete_tracked(existing)
                            newTp = f_draw_pattern(c)
                            array.set(trackedPatterns, overlapIdx, newTp)
                            newWedgeFound := true
                            f_log("PATTERN REPLACED type=" + c.ttype + " score=" + str.tostring(c.score))

    // Cleans up any same-direction overlap chain the pairwise checks above
    // can miss (A overlaps B, B overlaps C, but A and C don't directly
    // overlap each other).
    f_dedupe_overlaps()

// ============================================================================
// Breakout tracking (every bar — price can break either boundary any time)
// ============================================================================

breakoutBull = false
breakoutBear = false

if array.size(trackedPatterns) > 0
    for i = array.size(trackedPatterns) - 1 to 0
        tp = array.get(trackedPatterns, i)
        // Evaluated at min(bar_index, capX) instead of bar_index directly: once
        // a pattern has run past its fixed extension budget (one more
        // window-width beyond its own formation, see f_line_cap_x), its
        // boundary level FREEZES at whatever it was at the cap instead of
        // continuing to extrapolate forever. A pattern that hasn't broken
        // that frozen level within a reasonable time is stale (handled by
        // `aged` below), not still meaningfully "live".
        capX = f_line_cap_x(tp.p)
        evalX = math.min(bar_index, capX)
        upperNow = f_price(tp.p.xU1, tp.p.yU1, tp.p.xU2, tp.p.yU2, evalX)
        lowerNow = f_price(tp.p.xL1, tp.p.yL1, tp.p.xL2, tp.p.yL2, evalX)
        // One-bar grace period only, not a "fresh cross" requirement — same
        // reasoning as Triangle Compression Scanner Pro: skips the check on
        // the pattern's own creation bar (avoids an instant breakout label
        // the same bar the wedge is drawn) and falls back to a plain
        // containment check every bar after.
        justCreated = bar_index <= tp.createdBar
        bullHit = not justCreated and close > upperNow
        bearHit = not justCreated and close < lowerNow
        // Classic TA treats a pattern that hasn't resolved within a
        // reasonable multiple of its own formation length as stale, not
        // live — same principle applied here. This is the sole cleanup
        // mechanism now (an ATR-based drift check was tried and removed:
        // log-confirmed it was killing legitimate, already-capped patterns
        // on arrival — ATR measures bar-to-bar noise, not the honest
        // cumulative price range a wedge's own real slope implies over
        // dozens-to-hundreds of bars, so it was never a calibrated way to
        // judge this).
        windowBars = tp.p.windowEndX - tp.p.windowStartX
        aged = bar_index - tp.p.windowEndX > maxPatternAgeMult * math.max(windowBars, 1)
        if bullHit or bearHit
            breakoutBull := breakoutBull or bullHit
            breakoutBear := breakoutBear or bearHit
            col = bullHit ? bullCol : bearCol
            txt = bullHit ? "Breakout ↑" : "Breakdown ↓"
            lb = label.new(bar_index, bullHit ? upperNow : lowerNow, txt,
                 style=bullHit ? label.style_label_up : label.style_label_down,
                 color=col, textcolor=color.white, size=size.small)
            array.push(tp.objLabels, lb)
            f_log("BREAKOUT bull=" + str.tostring(bullHit) + " type=" + tp.p.ttype)
            if showOldPatterns
                f_finalize_lines(tp)
            else
                f_delete_tracked(tp)
            array.remove(trackedPatterns, i)
        else if aged
            f_log("PATTERN EXPIRED type=" + tp.p.ttype + " score=" + str.tostring(tp.p.score) + " age=" + str.tostring(bar_index - tp.p.windowEndX))
            if showOldPatterns
                f_finalize_lines(tp)
            else
                f_delete_tracked(tp)
            array.remove(trackedPatterns, i)
        else if array.size(tp.objLines) >= 2
            // Still open: keep advancing the boundary lines' far end to stay
            // just `lineAheadBars` ahead of the current bar, capped at capX so
            // it stops growing once the fixed extension budget is used up
            // instead of drawing all the way across the chart.
            aheadX = math.min(bar_index + lineAheadBars, capX)
            line.set_xy2(array.get(tp.objLines, 0), aheadX, f_price(tp.p.xU1, tp.p.yU1, tp.p.xU2, tp.p.yU2, aheadX))
            line.set_xy2(array.get(tp.objLines, 1), aheadX, f_price(tp.p.xL1, tp.p.yL1, tp.p.xL2, tp.p.yL2, aheadX))

// ============================================================================
// Near-miss candidate marker (Watch signal, visual only)
// ============================================================================

plotshape(newSwing and showCandidate and best.score >= candidateThreshold and not best.valid and best.structureOk, title="Wedge Candidate", style=shape.circle, location=location.abovebar, size=size.tiny, color=candidateCol)

// ============================================================================
// Dashboard
// ============================================================================

if showDashboard and barstate.islast
    statusActive = array.size(trackedPatterns) > 0

    // Reflects the actually tracked pattern when one exists. Without one, a
    // valid candidate is never "ready": a valid, open, non-aged candidate is
    // tracked the moment it is found, so an untracked valid `best` is always
    // stillborn, aged out or already resolved. Only a near-miss (structure
    // OK, not valid — the same rule as the chart's candidate marker) is shown
    // as WATCH; otherwise SCAN with no pattern details.
    dashWatch = not statusActive and best.structureOk and best.score >= candidateThreshold and not best.valid
    Pattern dashPattern = emptyPattern()
    if statusActive
        dashPattern := array.get(trackedPatterns, f_find_best_active_index()).p
    else if dashWatch
        dashPattern := best
    dashStatus = statusActive ? "ACTIVE" : dashWatch ? "WATCH" : "SCAN"

    table.cell(dash, 0, 0, "Broadening Scanner", text_color=tc, bgcolor=hd, text_size=size.small)
    table.cell(dash, 1, 0, dashStatus, text_color=color.white, bgcolor=statusActive ? color.new(wedgeCol, 0) : dashWatch ? candidateCol : color.new(color.gray, 80), text_size=size.small)

    table.cell(dash, 0, 1, "Active Patterns", text_color=tc, text_size=size.small)
    table.cell(dash, 1, 1, str.tostring(array.size(trackedPatterns)) + " / " + str.tostring(maxConcurrentPatterns), text_color=tc, text_size=size.small)

    table.cell(dash, 0, 2, "Best Score", text_color=tc, text_size=size.small)
    table.cell(dash, 1, 2, str.tostring(dashPattern.score) + " / " + str.tostring(minScore), text_color=tc, text_size=size.small)

    table.cell(dash, 0, 3, "Type", text_color=tc, text_size=size.small)
    table.cell(dash, 1, 3, dashPattern.structureOk ? dashPattern.ttype : "—", text_color=tc, text_size=size.small)

    table.cell(dash, 0, 4, "Window", text_color=tc, text_size=size.small)
    table.cell(dash, 1, 4, na(dashPattern.windowStartX) ? "—" : (str.tostring(dashPattern.windowEndX - dashPattern.windowStartX) + " Bars"), text_color=tc, text_size=size.small)

// ============================================================================
// Debug: score breakdown of the current best-in-depth candidate
// ============================================================================

if showDebug and barstate.islast
    table.cell(dbgTable, 0, 0, "Score (Σ/80 → 100)", text_color=tc, bgcolor=hd, text_size=size.small)
    table.cell(dbgTable, 1, 0, str.tostring(best.score) + " / " + str.tostring(minScore), text_color=tc, bgcolor=hd, text_size=size.small)

    table.cell(dbgTable, 0, 1, "Swing-Pfad (Gate)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 1, str.tostring(best.pathScore) + (best.pathOk ? " · OK" : " · GATE"), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 2, "Pfad-Fortschritt", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 2, str.tostring(best.progressionMatches) + "/" + str.tostring(best.progressionTests), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 3, "Traversal (20)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 3, str.tostring(best.traversalScore) + (best.traversalOk ? " · OK" : " · GATE"), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 4, "Real. Expansion (15)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 4, str.tostring(best.rangeScore) + (best.realizedExpansionOk ? " · OK" : " · GATE"), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 5, "Real Ratio / Wachstum", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 5, str.tostring(best.realizedExpansionRatio, "#.##") + " / " + str.tostring(best.legGrowthMatches) + "/" + str.tostring(best.legGrowthTests), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 6, "Proj. Expansion", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 6, str.tostring(best.projectedExpansionRatio, "#.##") + "×", text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 7, "Zusatzkontakte (15)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 7, str.tostring(best.touchScore), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 8, "Linienqualität (10)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 8, str.tostring(best.lineScore) + (best.lineQualityOk ? " · OK" : " · GATE"), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 9, "Anker-Span (10)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 9, str.tostring(best.anchorScore) + " · " + str.tostring(best.upperAnchorSpanRatio, "#.##") + "/" + str.tostring(best.lowerAnchorSpanRatio, "#.##") + (best.anchorSpanOk ? " · OK" : " · GATE"), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 10, "Boundary-Drift (ATR)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 10, str.tostring(best.upperDriftAtr, "#.##") + "/" + str.tostring(best.lowerDriftAtr, "#.##") + (best.driftOk ? " · OK" : " · GATE"), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 11, "Linienordnung / Breite", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 11, (best.boundariesOrderedOk ? "O" : "O GATE") + " / " + (best.widthOk ? "B" : "B GATE"), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 12, "Dauer (5)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 12, str.tostring(best.durationScore) + (best.durationOk ? " · OK" : " · GATE"), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 13, "Aktualität (5)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 13, str.tostring(best.relevanceScore), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 14, "Containment (soft)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 14, str.tostring(best.containmentRatio * 100.0, "#.0") + " %" + (best.barsInsideOk ? " · OK" : ""), text_color=tc, text_size=size.small)

// ============================================================================
// Alerts
// ============================================================================

alertOk = not alertsOnBarClose or barstate.isconfirmed

alertcondition(alertOk and newWedgeFound, "Neuer Broadening Wedge erkannt", "BWS · WEDGE FOUND · {{ticker}} {{interval}}")
alertcondition(alertOk and breakoutBull, "Bullischer Breakout", "BWS · BULL BREAKOUT · {{ticker}} {{interval}}")
alertcondition(alertOk and breakoutBear, "Bärischer Breakdown", "BWS · BEAR BREAKDOWN · {{ticker}} {{interval}}")

Overview

Broadening Wedge Scanner Pro [WavesUnchained] evaluates confirmed swing highs and lows within a sliding window, fitting upper and lower trendlines based on ATR expansion and traversal rules. The script signals bullish or bearish breakouts when price breaches the boundaries on bar close, optionally displaying quality scores, candidate patterns, and an information dashboard. Users can adjust pivot strength, search depth, window size, and minimum score thresholds. Because swings require confirmation, newly formed wedges appear with a lag.

Details

Published by

casoon on GitHub

Type

Indicator, Pine Script v6

Draws

On the price chart

Category

Support & resistance, Breakout, Screeners & dashboards

Markets

Any symbol

Timeframes

Any timeframe

Risk Notes

No repainting, lookahead or stacked entries were found in its code or README. That says nothing about how well it works: test it first.

Topics

pine-scriptpinescripttechnical-analysistrading-indicatorstrading-strategiestradingview

Settings

The inputs you can change from the gear icon on the chart. Defaults are the author's.

SettingTypeDefaultVariable
Swing-Stärkeint6pivotLen
Max. gespeicherte Swingsint80maxSwings
Suchbereich letzte Swingsint24searchDepth
Swings pro Keil-Fensterint8wedgeWindow
Max. gleichzeitige Patternsint1maxConcurrentPatterns
Max. Pattern-Alter (× Fensterbreite)float1.5maxPatternAgeMult
Mindestqualitätint60minScore
ATR-Längeint14atrLen
Touch-Toleranz ATRfloat0.5touchTolAtr
Ziel proj. Endbreite / Startbreitefloat1.3projectedExpansionTarget
Mindest-Endbreite ATRfloat1.0minWidthAtr
Min. Geschwindigkeits-Verhältnis (DBW/ABW)float1.2fasterRatioMin
Show all 29 settings
SettingTypeDefaultVariable
Min. Traversal pro Legfloat0.60minTraversalRatio
Min. realisierte Leg-Expansionfloat1.15minRealizedExpansionRatio
Min. Anker-Span / Pattern-Dauerfloat0.55minAnchorSpanRatio
Max. Boundary-Drift ATRfloat8.0maxBoundaryDriftAtr
Aufgelöste Patterns behaltenboolfalseshowOldPatterns
Kandidaten anzeigenbooltrueshowCandidate
Linien-Überstand (Bars)int10lineAheadBars
Score-Label anzeigenbooltrueshowLabels
Keil (offen)colorcolor.purplewedgeCol
Bullischer Breakoutcolorcolor.limebullCol
Bärischer Breakoutcolorcolor.redbearCol
Kandidatcolorcolor.new(color.orange, 0)candidateCol
Score-Breakdown Debug-TabelleboolfalseshowDebug
Kandidat-Mindestscore (Watch)int25candidateThreshold
Pine-Logs aktivieren (Fehlersuche)boolfalseenableLogging
Dashboard anzeigenbooltrueshowDashboard
Alerts nur bei bestätigter Kerze (Bar Close)booltruealertsOnBarClose
Third-party code. ForexR did not write, test or endorse this script, and the summary above was generated automatically from its code and README. Indicators describe the past, and a strategy's backtest can look better than live trading, especially when a script repaints. Check it in the Strategy Tester and on a demo account before you rely on it.

Similar Pine Scripts

ORB Pro - Research-Based ORB

by Mrshahidali420
16 stars

The ORB Pro indicator calculates and plots Opening Range Breakout levels, stop-loss and take-profit zones, signal labels, and an status dashboard on the price chart. Built on academic research, it evaluates trades using an opening-candle direction filter, open

Indicator v6 BreakoutSessions & time Repaints
Updated 3 months ago · None stated GitHub repository of ORB Pro - Research-Based ORB

ORB Algo | Flux Charts

by vaskosmihaylov
5 stars

This indicator calculates and visualizes Opening Range Breakout (ORB) zones directly on the price chart. Breakout signals are identified when the close or an EMA crosses the opening range boundary, with entry sensitivity managed by retest requirements. It mode

Indicator v6 BreakoutSessions & time
Updated 2 months ago · GPL-3.0 GitHub repository of ORB Algo | Flux Charts

TheStrat Setup Screener

by 345-strategies
5 stars

This indicator screens Rob Smith's TheStrat setups across six configurable timeframes for TradingView's Pine Screener. It classifies bars and exposes inside reversals, 2-2 reversals and continuations, failing 2s, range expansions, and outside bars as numeric p

Indicator v6 Candlestick patternsSupport & resistance Repaints
Updated 2 days ago · MPL-2.0 GitHub repository of TheStrat Setup Screener

Darvas Box AL SAT

by gocaman
3 stars

Calculates Darvas Box upper and lower channel boundaries based on configurable period length to identify trend breakouts. It plots top and bottom box levels directly on the price chart and plots buy (AL) and sell (SAT) signal labels when price crosses outside

Indicator v5 Support & resistanceBreakout
Updated today · None stated GitHub repository of Darvas Box AL SAT
2 stars

Calculates the statistical likelihood of price reaching multiple percentage-spaced breakout levels above and below the current price. It renders horizontal lines with percentage probability labels directly on the price chart and maintains a summary dashboard i

Indicator v5 BreakoutSupport & resistance
Updated 8 months ago · None stated GitHub repository of Breakout Probability (Expo)

More indicators

Trade Your Charts With a Regulated Broker

Compare brokers that connect to TradingView, with spreads, minimum deposits and leverage side by side.

Compare brokers