Indicator · Pine Script v6 · On the chart

Triangle Compression Scanner Pro [WavesUnchained]

by casoon

This indicator scans price action for symmetric, ascending, and descending triangle chart patterns using a sliding window of confirmed swing pivots.

View on GitHub

GitHub stars9on the repository
Pine versionv6current
Lines of code1,157
Settings31inputs
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,157 lines of Pine Script v6 from indicators/market_structure/triangle_compression_scanner_pro/triangle_compression_scanner_pro.pine, published under MIT. Keep the author's credit and licence lines when you reuse it.

triangle_compression_scanner_pro.pine
//@version=6
// ============================================================================
// Triangle Compression 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 compressing triangles — symmetric, ascending, or
// descending. Every distinct, non-overlapping valid triangle is tracked
// independently until a confirmed breakout resolves it.
//
// Features:
//   ✓ Sliding-window search (not a fixed point count like a harmonic pattern)
//     across consecutive swing windows within the search depth
//   ✓ Tracks multiple simultaneously valid, non-overlapping triangles at
//     once, each with its own drawings and breakout state
//   ✓ Weighted 0-100 score: traversal, realized compression, boundary
//     reliability, apex maturity, duration, and current relevance
//   ✓ Live breakout tracking (either direction resolves the pattern) + alert
//   ✓ Near-miss candidate preview (Watch signal) for the best-in-window shape
//   ✓ Score-breakdown debug table
//
// ============================================================================

indicator("Triangle Compression 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)
triangleWindow        = input.int(8, "Swings pro Dreieck-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")
flatSlopeAtrTol      = input.float(0.5, "Flat-Toleranz (ATR über Fensterbreite)", minval=0.05, step=0.05)
touchTolAtr          = input.float(0.5, "Touch-Toleranz ATR", minval=0.05, step=0.05)
projectedCompressionTarget = input.float(0.75, "Ziel proj. Endbreite / Startbreite", minval=0.3, maxval=0.95, step=0.05)
minWidthAtr          = input.float(1.0, "Mindest-Startbreite ATR", minval=0.2, step=0.1)
minTraversalRatio   = input.float(0.55, "Min. Traversal pro Leg", minval=0.40, maxval=0.90, step=0.05)
maxRealizedCompressionRatio = input.float(0.85, "Max. realisierte Leg-Kompression", minval=0.40, maxval=1.0, step=0.05)
minAnchorSpanRatio  = input.float(0.45, "Min. Anker-Span / Pattern-Dauer", minval=0.30, maxval=0.90, step=0.05)
maxMeanResidualAtr = input.float(1.0, "Max. mittleres Boundary-Residuum ATR", minval=0.25, maxval=3.0, step=0.25)
maxApexDistanceMult = input.float(1.25, "Max. Apex-Distanz (× Pattern-Dauer)", minval=0.25, maxval=3.0, step=0.25)
breakoutBufferAtr   = input.float(0.15, "Breakout-Puffer ATR", minval=0.0, maxval=1.0, step=0.05)

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

triangleCol       = input.color(color.blue, "Dreieck (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)

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

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

type Pattern
    bool valid
    string ttype          // "Symmetrisch" | "Ascending" | "Descending" | "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 realizedCompressionOk
    bool boundaryOk
    bool apexOk
    bool lineQualityOk
    bool durationOk
    bool barsInsideOk
    int progressionMatches
    int progressionTests
    int longestTraversalRun
    float averageTraversal
    float upperAnchorSpanRatio
    float lowerAnchorSpanRatio
    float meanResidualAtr
    bool swingInsideOk
    bool distributionOk
    bool flatSideOk
    int upperZoneCount
    int lowerZoneCount
    float slopeRatio
    float apexDistanceRatio
    float apexProgress
    float containmentRatio
    int pathScore
    int traversalScore
    int compressionScore
    int touchScore
    int lineScore
    int boundaryScore
    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,
         0, 0, 0, na, na, na, na, false, false, false, 0, 0, na, na, 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 triangle found at once.
var Pattern best = emptyPattern()

var trackedPatterns = array.new<TrackedPattern>()

// Resolved patterns move here only when showOldPatterns keeps their
// drawings around — kept purely so a later overlapping candidate can be
// compared against the score of the historical shape still on the chart,
// instead of silently drawing a second, lower-quality triangle on top of it.
var keptPatterns = 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, 17,
     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("TCS " + 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)

f_proximity_quality(float distanceAtr) =>
    distanceAtr <= 0.25 ? 1.0 : distanceAtr >= 0.75 ? 0.0 : 1.0 - (distanceAtr - 0.25) / 0.50

// 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 widest
// anchor span, the smallest mean residual, and finally the most touches.
// 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
// `triangleWindow`/`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
    bestMeanResidual = 1000000000.0
    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
            residualSum = 0.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)
                residualSum += math.abs(dist)
                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
            meanResidual = residualSum / float(n)
            better = violations < bestViolations or (violations == bestViolations and (span > bestSpan or (span == bestSpan and (meanResidual < bestMeanResidual or (meanResidual == bestMeanResidual and touches > bestTouches)))))
            if better
                bestViolations := violations
                bestTouches := touches
                bestSpan := span
                bestMeanResidual := meanResidual
                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 triangle 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, TrackedPattern[] arr) =>
    n = array.size(arr)
    idx = -1
    if n > 0
        for i = 0 to n - 1
            tp = array.get(arr, 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` triangles 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 lines into a bounded segment
// ending at the current bar (instead of drifting on extend.right forever)
// and moves it into keptPatterns, so a future overlapping candidate is still
// compared against its score. Shared by every path that displaces a
// still-open pattern while `showOldPatterns` is on: overlap replacement, cap
// eviction, breakout resolution, and overlap-chain dedupe.
f_finalize_and_keep(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)
    array.push(keptPatterns, tp)

// 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_and_keep(tp)
                else
                    f_delete_tracked(tp)
                array.remove(trackedPatterns, i)

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

f_eval_triangle(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)

    windowBars = windowEndX - windowStartX
    touchTolPrice = touchTolAtr * atr

    // Boundaries are the two real touch points that best act as a resistance
    // (upper) / support (lower) line — fewest violations, broad anchor span,
    // and small residuals — not a least-squares average through the 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)

    // "Flat" is defined relative to how much the line would move across the
    // whole window, in ATR terms — not an absolute slope value, so it scales
    // sensibly across symbols/timeframes.
    flatTolPrice = windowBars > 0 ? flatSlopeAtrTol * atr / windowBars : flatSlopeAtrTol * atr

    upperFlat = not na(slopeU) and math.abs(slopeU) < flatTolPrice
    lowerFlat = not na(slopeL) and math.abs(slopeL) < flatTolPrice
    upperFalling = not na(slopeU) and slopeU < 0 and not upperFlat
    lowerRising  = not na(slopeL) and slopeL > 0 and not lowerFlat

    isSymmetric  = upperFalling and lowerRising
    isAscending  = upperFlat and lowerRising
    isDescending = upperFalling and lowerFlat

    ttype = isSymmetric ? "Symmetrisch" : isAscending ? "Ascending" : isDescending ? "Descending" : "None"
    structureOk = isSymmetric or isAscending or isDescending

    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)

    // Signed, not abs(): an inverted channel is invalid geometry, not a
    // positive width that should be rescued by an absolute value.
    widthStart = upperAtStart - lowerAtStart
    widthMid   = upperAtMid - lowerAtMid
    widthEnd   = upperAtEnd - lowerAtEnd

    projectedCompressionRatio = widthStart > 0 ? widthEnd / widthStart : 1000000.0
    compressionGeometryOk = widthStart > minWidthAtr * atr and widthMid > 0 and widthEnd > 0 and widthMid <= widthStart and widthEnd <= widthMid and projectedCompressionRatio <= projectedCompressionTarget

    // The same-side extremum sequence must confirm the triangle family. The
    // flat side of ascending/descending triangles is validated separately by
    // repeated boundary contacts instead of an artificial monotonic rule.
    alternatingPath = true
    if nPath > 1
        for i = 1 to nPath - 1
            if array.get(pathDir, i) == array.get(pathDir, i - 1)
                alternatingPath := false
    progressionTests = 0
    progressionMatches = 0
    if (isSymmetric or isDescending) and nH > 1
        for i = 1 to nH - 1
            progressionTests += 1
            if array.get(highY, i) < array.get(highY, i - 1)
                progressionMatches += 1
    if (isSymmetric or isAscending) and nL > 1
        for i = 1 to nL - 1
            progressionTests += 1
            if array.get(lowY, i) > array.get(lowY, i - 1)
                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 progressionTests >= 2 and progressionMatches == progressionTests
    pathScore = structureOk and alternatingPath ? int(math.round(20.0 * progressionRatio)) : 0

    // Boundary-to-boundary utilization: a generous outer envelope no longer
    // qualifies merely because it contains the price. At least three
    // consecutive alternating legs must traverse most of the local width.
    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 compression compares actual price legs. Same-direction legs
    // are compared with one another so impulse/retracement alternation does
    // not create a false failure.
    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
    realizedCompressionRatio = firstLegAverage > 0 ? lastLegAverage / firstLegAverage : 1000000.0
    legShrinkTests = 0
    legShrinkMatches = 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)
                legShrinkTests += 1
                if legAmplitude <= previousSameDirection * 1.05
                    legShrinkMatches += 1
            if legEndsAtHigh
                previousUpLeg := legAmplitude
            else
                previousDownLeg := legAmplitude
    legShrinkRatio = legShrinkTests > 0 ? float(legShrinkMatches) / float(legShrinkTests) : 0.0
    realizedCompressionOk = compressionGeometryOk and legCount >= 5 and realizedCompressionRatio <= maxRealizedCompressionRatio and legShrinkRatio >= 0.60
    realizedRatioQuality = maxRealizedCompressionRatio < 1.0 ? f_clamp01((1.0 - realizedCompressionRatio) / (1.0 - maxRealizedCompressionRatio)) : (realizedCompressionRatio <= 1.0 ? 1.0 : 0.0)
    projectedRatioQuality = f_clamp01((1.0 - projectedCompressionRatio) / (1.0 - projectedCompressionTarget))
    compressionScore = int(math.round(15.0 * (0.70 * realizedRatioQuality + 0.20 * legShrinkRatio + 0.10 * projectedRatioQuality)))

    upperAnchorSpanRatio = windowBars > 0 ? float(xU2 - xU1) / float(windowBars) : 0.0
    lowerAnchorSpanRatio = windowBars > 0 ? float(xL2 - xL1) / float(windowBars) : 0.0

    extraHighTouches = 0
    highResidualSum = 0.0
    highViolations = 0
    upperEarly = false
    upperMiddle = false
    upperLate = false
    proximitySum = 0.0
    proximityCount = 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
            absDist = math.abs(dist)
            highResidualSum += absDist
            isAnchor = hx == xU1 or hx == xU2
            if not isAnchor
                proximitySum += f_proximity_quality(atr > 0 ? absDist / atr : 1000000.0)
                proximityCount += 1
            if absDist <= touchTolPrice
                if not isAnchor
                    extraHighTouches += 1
                positionRatio = windowBars > 0 ? float(hx - windowStartX) / float(windowBars) : 0.0
                if positionRatio < 0.333333
                    upperEarly := true
                else if positionRatio < 0.666667
                    upperMiddle := true
                else
                    upperLate := true
            else if dist > touchTolPrice
                highViolations += 1
    extraLowTouches = 0
    lowResidualSum = 0.0
    lowViolations = 0
    lowerEarly = false
    lowerMiddle = false
    lowerLate = false
    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
            absDist = math.abs(dist)
            lowResidualSum += absDist
            isAnchor = lx == xL1 or lx == xL2
            if not isAnchor
                proximitySum += f_proximity_quality(atr > 0 ? absDist / atr : 1000000.0)
                proximityCount += 1
            if absDist <= touchTolPrice
                if not isAnchor
                    extraLowTouches += 1
                positionRatio = windowBars > 0 ? float(lx - windowStartX) / float(windowBars) : 0.0
                if positionRatio < 0.333333
                    lowerEarly := true
                else if positionRatio < 0.666667
                    lowerMiddle := true
                else
                    lowerLate := true
            else if dist < -touchTolPrice
                lowViolations += 1

    touchScore = int(math.round(15.0 * (proximityCount > 0 ? proximitySum / float(proximityCount) : 0.0)))
    upperZoneCount = (upperEarly ? 1 : 0) + (upperMiddle ? 1 : 0) + (upperLate ? 1 : 0)
    lowerZoneCount = (lowerEarly ? 1 : 0) + (lowerMiddle ? 1 : 0) + (lowerLate ? 1 : 0)
    distributionOk = upperZoneCount >= 2 and lowerZoneCount >= 2 and (upperLate or lowerLate)
    flatSideOk = isAscending ? extraHighTouches >= 1 : isDescending ? extraLowTouches >= 1 : true

    pointCount = nH + nL
    meanResidual = pointCount > 0 ? (highResidualSum + lowResidualSum) / float(pointCount) : atr * 1000000.0
    meanResidualAtr = atr > 0 ? meanResidual / atr : 1000000.0
    swingInsideOk = highViolations == 0 and lowViolations == 0
    boundaryOk = upperAnchorSpanRatio >= minAnchorSpanRatio and lowerAnchorSpanRatio >= minAnchorSpanRatio and meanResidualAtr <= maxMeanResidualAtr and swingInsideOk and distributionOk and flatSideOk

    // For a symmetric triangle, neither side should be wildly steeper than
    // the other (a near-flat side paired with a near-vertical one never
    // looks like a real converging triangle, even if it technically
    // qualifies) — same 3x-ratio reasoning as Wolfe Wave Scanner.
    slopeRatio = isSymmetric and not na(slopeU) and not na(slopeL) and math.abs(slopeL) > 0.0000001 ? math.abs(slopeU) / math.abs(slopeL) : na
    lineQualityOk = isSymmetric ? (not na(slopeRatio) and slopeRatio >= 0.3 and slopeRatio <= 3.0) : structureOk

    durationOk = windowBars >= pivotLen * 3
    durationScore = int(math.round(5.0 * f_clamp01(float(windowBars) / float(pivotLen * 6))))

    // For two converging straight lines the apex is their intersection. It
    // must still lie ahead, but not so far away that the fitted boundaries
    // are effectively unrelated trends. Maturity contributes continuously.
    convergenceSlope = slopeU - slopeL
    apexX = not na(convergenceSlope) and convergenceSlope < 0 ? float(windowStartX) - widthStart / convergenceSlope : na
    apexDistance = not na(apexX) ? apexX - float(windowEndX) : -1.0
    apexDistanceRatio = windowBars > 0 ? apexDistance / float(windowBars) : -1.0
    apexProgress = not na(apexX) and apexX > float(windowStartX) ? float(windowBars) / (apexX - float(windowStartX)) : 0.0
    apexOk = not na(apexX) and apexDistance > 0 and apexDistance <= maxApexDistanceMult * float(math.max(windowBars, 1))
    apexQuality = f_clamp01(1.0 - math.abs(apexProgress - 0.65) / 0.35)
    anchorQuality = (f_clamp01(upperAnchorSpanRatio) + f_clamp01(lowerAnchorSpanRatio)) / 2.0
    boundaryScore = int(math.round(10.0 * (0.60 * anchorQuality + 0.40 * apexQuality)))

    // Bar containment is soft. Residuals, distributed contacts, and traversal
    // decide whether both boundaries are functional; an isolated wick no
    // longer discards an otherwise coherent triangle outright.
    barsInsideCount = 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]
        evaluatedBars += 1
        if barHigh <= upperAt + touchTolPrice and barLow >= lowerAt - touchTolPrice
            barsInsideCount += 1
    containmentRatio = evaluatedBars > 0 ? float(barsInsideCount) / float(evaluatedBars) : 0.0
    barsInsideOk = containmentRatio >= 0.90
    residualQuality = 1.0 - f_clamp01(meanResidualAtr / maxMeanResidualAtr)
    violationRatio = pointCount > 0 ? float(highViolations + lowViolations) / float(pointCount) : 1.0
    distributionQuality = (float(upperZoneCount) + float(lowerZoneCount)) / 6.0
    lineScore = int(math.round(10.0 * (0.45 * residualQuality + 0.35 * containmentRatio + 0.10 * (1.0 - violationRatio) + 0.10 * distributionQuality)))

    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 + compressionScore + touchScore + lineScore + boundaryScore + durationScore + relevanceScore
    score = int(math.round(float(rawScore) * 100.0 / 80.0))
    hardGatesOk = structureOk and pathOk and traversalOk and realizedCompressionOk and boundaryOk and apexOk 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, realizedCompressionOk, boundaryOk, apexOk, lineQualityOk, durationOk, barsInsideOk,
         progressionMatches, progressionTests, longestTraversalRun, averageTraversal,
         upperAnchorSpanRatio, lowerAnchorSpanRatio, meanResidualAtr,
         swingInsideOk, distributionOk, flatSideOk, upperZoneCount, lowerZoneCount,
         slopeRatio, apexDistanceRatio, apexProgress, containmentRatio,
         pathScore, traversalScore, compressionScore, touchScore, lineScore, boundaryScore, 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 on
// Broadening Wedge Scanner Pro: 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), triangleCol, line.style_solid, 2)
    f_line(objLines, p.xL1, p.yL1, aheadX, f_price(p.xL1, p.yL1, p.xL2, p.yL2, aheadX), triangleCol, line.style_solid, 2)

    if showLabels
        txt = p.ttype + " Triangle\nScore " + str.tostring(p.score)
        lb = label.new(p.windowEndX, p.yU2, txt, style=label.style_label_down, color=triangleCol, 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
// triangles 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

newTriangleFound = false

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

if newSwing and sz >= triangleWindow
    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 `triangleWindow`, leaving no room for
    // a single full window inside the search range.
    firstEnd = math.max(start + triangleWindow - 1, triangleWindow - 1)
    if firstEnd <= sz - 1
        for end = firstEnd to sz - 1
            winStart = end - triangleWindow + 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_triangle(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) + " realizedCompression=" + str.tostring(best.realizedCompressionOk)
         + " traversalRun=" + str.tostring(best.longestTraversalRun) + " traversalAvg=" + str.tostring(best.averageTraversal, "#.###")
         + " boundary=" + str.tostring(best.boundaryOk) + " anchorU/L=" + str.tostring(best.upperAnchorSpanRatio, "#.###") + "/" + str.tostring(best.lowerAnchorSpanRatio, "#.###")
         + " residualAtr=" + str.tostring(best.meanResidualAtr, "#.##") + " swingInside=" + str.tostring(best.swingInsideOk)
         + " distribution=" + str.tostring(best.distributionOk) + " flatSide=" + str.tostring(best.flatSideOk)
         + " apex=" + str.tostring(best.apexOk) + " apexDistanceRatio=" + str.tostring(best.apexDistanceRatio, "#.##")
         + " apexProgress=" + str.tostring(best.apexProgress, "#.##") + " lineQuality=" + str.tostring(best.lineQualityOk)
         + " slopeRatio=" + str.tostring(best.slopeRatio, "#.##") + " duration=" + str.tostring(best.durationOk))

    // Lightweight candidate triangle 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)
        candidateAnchorOk = best.upperAnchorSpanRatio >= minAnchorSpanRatio and best.lowerAnchorSpanRatio >= minAnchorSpanRatio
        candidateReason = not best.pathOk ? "Swing-Pfad" : not best.traversalOk ? "Traversal" : not best.realizedCompressionOk ? "Kompression" : not candidateAnchorOk ? "Anker-Span" : not best.swingInsideOk ? "Swing-Grenze" : not best.distributionOk ? "Kontaktverteilung" : not best.flatSideOk ? "Flat-Seite" : not best.boundaryOk ? "Boundary" : not best.apexOk ? "Apex" : 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 triangle 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. Without this check, most detected triangles
                // are stillborn: already broken out the instant they're
                // found, so they'd be drawn and deleted one bar later
                // without ever being 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)
                breakoutBuffer = breakoutBufferAtr * atr
                stillOpen = close < upperAtNow + breakoutBuffer and close > lowerAtNow - breakoutBuffer
                // `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 on Broadening Wedge Scanner Pro: 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
                    // A resolved-and-kept triangle no longer sits in
                    // trackedPatterns, so without this check a later
                    // candidate at the same spot would draw right on top of
                    // it regardless of quality. Only let the new one through
                    // if it actually outscores the kept one there.
                    keptOverlapIdx = f_find_overlap_index(c, keptPatterns)
                    keptBlocks = keptOverlapIdx >= 0 and array.get(keptPatterns, keptOverlapIdx).p.score >= c.score
                    if keptBlocks
                        f_log("PATTERN SKIPPED (kept overlap scores higher) type=" + c.ttype + " score=" + str.tostring(c.score))
                    if not keptBlocks
                        if keptOverlapIdx >= 0
                            outscoredKept = array.get(keptPatterns, keptOverlapIdx)
                            f_delete_tracked(outscoredKept)
                            array.remove(keptPatterns, keptOverlapIdx)
                        overlapIdx = f_find_overlap_index(c, trackedPatterns)
                        if overlapIdx < 0
                            if array.size(trackedPatterns) < maxConcurrentPatterns
                                newTp = f_draw_pattern(c)
                                array.push(trackedPatterns, newTp)
                                newTriangleFound := 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 triangle if it outscores it —
                                // otherwise the first `maxConcurrentPatterns` triangles
                                // 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_and_keep(weakest)
                                    else
                                        f_delete_tracked(weakest)
                                    newTp = f_draw_pattern(c)
                                    array.set(trackedPatterns, weakestIdx, newTp)
                                    newTriangleFound := 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_and_keep(existing)
                                else
                                    f_delete_tracked(existing)
                                newTp = f_draw_pattern(c)
                                array.set(trackedPatterns, overlapIdx, newTp)
                                newTriangleFound := 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()

    // `keptPatterns` exists purely so a future overlapping candidate can be
    // compared against a resolved shape's score instead of drawing over it —
    // but with no cleanup, every resolved pattern piled up here forever
    // across the entire chart history (default `Aufgelöste Patterns
    // behalten` is on), growing `f_find_overlap_index(c, keptPatterns)`'s
    // linear scan cost on every single search event, year after year. A kept
    // entry old enough that even a LIVE tracked pattern of the same age
    // would already have expired can never usefully be compared against
    // again — no future search window reaches that far back — so it's
    // trimmed on the same age basis instead of kept indefinitely.
    if array.size(keptPatterns) > 0
        for i = array.size(keptPatterns) - 1 to 0
            kp = array.get(keptPatterns, i)
            kpWindowBars = kp.p.windowEndX - kp.p.windowStartX
            if bar_index - kp.p.windowEndX > maxPatternAgeMult * math.max(kpWindowBars, 1)
                f_delete_tracked(kp)
                array.remove(keptPatterns, i)

// ============================================================================
// 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. A
        // fresh-cross check (close[1] on the inside, close now outside)
        // sounds cleaner, but windowEndX is always in the past by
        // construction (pivotLen swing-confirmation lag), so it can never
        // gate anything; and if price is already outside the very first
        // time a pattern is tracked, the "fresh cross" moment already
        // happened and never recurs — the pattern then sits in
        // trackedPatterns forever, permanently occupying one of the
        // maxConcurrentPatterns slots. This just skips the check on the
        // pattern's own creation bar (avoids labeling a breakout in the
        // same instant the triangle is drawn) and falls back to a plain
        // containment check every bar after, so a pattern that's already
        // outside always resolves on the very next bar.
        justCreated = bar_index <= tp.createdBar
        breakoutBuffer = breakoutBufferAtr * atr
        bullHit = not justCreated and close > upperNow + breakoutBuffer
        bearHit = not justCreated and close < lowerNow - breakoutBuffer
        // 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 on Broadening Wedge Scanner Pro it was killing
        // legitimate, already-capped patterns on arrival — ATR measures
        // bar-to-bar noise, not the honest cumulative price range a
        // triangle'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_and_keep(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_and_keep(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="Triangle 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, "Triangle Scanner", text_color=tc, bgcolor=hd, text_size=size.small)
    table.cell(dash, 1, 0, dashStatus, text_color=color.white, bgcolor=statusActive ? color.new(triangleCol, 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
    debugAnchorSpanOk = best.upperAnchorSpanRatio >= minAnchorSpanRatio and best.lowerAnchorSpanRatio >= minAnchorSpanRatio
    debugEvalX = best.structureOk ? math.min(bar_index, f_line_cap_x(best)) : bar_index
    debugUpperNow = best.structureOk ? f_price(best.xU1, best.yU1, best.xU2, best.yU2, debugEvalX) : na
    debugLowerNow = best.structureOk ? f_price(best.xL1, best.yL1, best.xL2, best.yL2, debugEvalX) : na
    debugBreakoutBuffer = breakoutBufferAtr * atr
    debugStillOpen = best.structureOk and close < debugUpperNow + debugBreakoutBuffer and close > debugLowerNow - debugBreakoutBuffer
    debugAlreadyAged = best.structureOk and bar_index - best.windowEndX > maxPatternAgeMult * math.max(best.windowEndX - best.windowStartX, 1)
    debugTrackability = not best.valid ? (best.score < minScore ? "SCORE" : "HARD GATE") : debugAlreadyAged ? "ZU ALT" : not debugStillOpen ? "AUSGEBROCHEN" : "TRACKABLE"

    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, "Traversal Run / Ø", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 4, str.tostring(best.longestTraversalRun) + "/3 · " + str.tostring(best.averageTraversal, "#.##"), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 5, "Real. Kompression (15)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 5, str.tostring(best.compressionScore) + (best.realizedCompressionOk ? " · OK" : " · GATE"), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 6, "Boundary-Nähe (15)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 6, str.tostring(best.touchScore), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 7, "Residual / Swings", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 7, str.tostring(best.meanResidualAtr, "#.##") + " ATR / " + (best.swingInsideOk ? "OK" : "GATE"), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 8, "Kontaktzonen / Flat", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 8, str.tostring(best.upperZoneCount) + "/" + str.tostring(best.lowerZoneCount) + " / " + (best.distributionOk ? "D OK" : "D GATE") + " / " + (best.flatSideOk ? "F OK" : "F GATE"), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 9, "Linienqualität (10)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 9, str.tostring(best.lineScore) + " · Ratio " + (na(best.slopeRatio) ? "n/a" : str.tostring(best.slopeRatio, "#.##")) + (best.lineQualityOk ? " · OK" : " · GATE"), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 10, "Anker U/L (10)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 10, str.tostring(best.boundaryScore) + " · " + str.tostring(best.upperAnchorSpanRatio, "#.###") + "/" + str.tostring(best.lowerAnchorSpanRatio, "#.###") + (debugAnchorSpanOk ? " · OK" : " · GATE"), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 11, "Apex Dist. / Reife", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 11, str.tostring(best.apexDistanceRatio, "#.##") + "× / " + str.tostring(best.apexProgress, "#.##") + (best.apexOk ? " · OK" : " · 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)

    table.cell(dbgTable, 0, 15, "Pattern-Gültigkeit", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 15, best.valid ? "VALID" : "INVALID", text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 16, "Trackability", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 16, debugTrackability, text_color=tc, text_size=size.small)

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

alertOk = not alertsOnBarClose or barstate.isconfirmed

alertcondition(alertOk and newTriangleFound, "Neues Dreieck erkannt", "TCS · TRIANGLE FOUND · {{ticker}} {{interval}}")
alertcondition(alertOk and breakoutBull, "Bullischer Breakout", "TCS · BULL BREAKOUT · {{ticker}} {{interval}}")
alertcondition(alertOk and breakoutBear, "Bärischer Breakdown", "TCS · BEAR BREAKDOWN · {{ticker}} {{interval}}")

Overview

Triangle Compression Scanner Pro [WavesUnchained] fits upper and lower boundary trend lines across swing highs and lows, rating each pattern with a composite quality score based on traversal, compression, boundary fit, and apex proximity. When price breaches a boundary by an ATR buffer, it flags a breakout. The indicator plots boundaries, status labels, and an optional metrics dashboard directly on the main chart.

Details

Published by

casoon on GitHub

Type

Indicator, Pine Script v6

Draws

On the price chart

Category

Breakout, Support & resistance

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 Dreieck-Fensterint8triangleWindow
Max. gleichzeitige Patternsint1maxConcurrentPatterns
Max. Pattern-Alter (× Fensterbreite)float1.5maxPatternAgeMult
Mindestqualitätint60minScore
ATR-Längeint14atrLen
Flat-Toleranz (ATR über Fensterbreite)float0.5flatSlopeAtrTol
Touch-Toleranz ATRfloat0.5touchTolAtr
Ziel proj. Endbreite / Startbreitefloat0.75projectedCompressionTarget
Mindest-Startbreite ATRfloat1.0minWidthAtr
Show all 31 settings
SettingTypeDefaultVariable
Min. Traversal pro Legfloat0.55minTraversalRatio
Max. realisierte Leg-Kompressionfloat0.85maxRealizedCompressionRatio
Min. Anker-Span / Pattern-Dauerfloat0.45minAnchorSpanRatio
Max. mittleres Boundary-Residuum ATRfloat1.0maxMeanResidualAtr
Max. Apex-Distanz (× Pattern-Dauer)float1.25maxApexDistanceMult
Breakout-Puffer ATRfloat0.15breakoutBufferAtr
Aufgelöste Patterns behaltenbooltrueshowOldPatterns
Kandidaten anzeigenbooltrueshowCandidate
Score-Label anzeigenbooltrueshowLabels
Linien-Überstand (Bars)int10lineAheadBars
Dreieck (offen)colorcolor.bluetriangleCol
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

Sideway Zones Live

by dearvn
257 stars

Sideway Zones Live is an overlay indicator that estimates inner and outer sideways bands from recent highs and lows with a rational quadratic kernel. It tracks swing pivots over a loopback period and counts bars that remain between the outer high and low. Afte

Indicator v5 TrendSupport & resistance
Updated 6 weeks ago · None stated GitHub repository of Sideway Zones Live
219 stars

This indicator identifies High Of Low Price (HOLP) and Low Of High Price (LOHP) candlestick setups based on price extremes over a specified lookback period. A HOLP signal triggers when the current bar closes above the high of a recent session low, whereas a LO

Indicator v5 Candlestick patternsBreakout
Updated 1 year ago · None stated GitHub repository of Candlestick Patterns - HOLP-LOHP
218 stars

This indicator plots dynamic support and resistance levels on the price chart. It detects pivot highs and lows using the pivot length and tracks touches to build strength-based levels. Support and resistance zones appear as colored boxes while lines mark key l

Indicator v6 Support & resistanceBreakout
Updated 3 months ago · None stated GitHub repository of Dynamic Support & Resistance

Donchian Channels (DC)

by mihakralj
99 stars

This indicator calculates and plots Donchian Channels directly on the price chart. It determines the highest high and lowest low over a user-selected lookback period using an optimized monotonic deque algorithm, as well as a middle basis line computed as their

Indicator v6 Volatility & bandsSupport & resistance
Updated 4 weeks ago · Apache-2.0 GitHub repository of Donchian Channels (DC)

Lows from New Highs

by be-thomas
65 stars

This indicator tracks bar lows during consecutive rising highs using Pine Script arrays. When at least four lows accumulate or the price breaches the minimum low in the stack, the script sorts the recorded lows in ascending order and flushes them. It plots up

Indicator v4 Support & resistanceBreakout
Updated 4 days ago · GPL-3.0 GitHub repository of Lows from New Highs

This indicator tracks smart money market structure directly on the price chart. It calculates dual-period swing highs and swing lows to detect Changes of Character (CHoCH), Breaks of Structure (BOS), inducements, and liquidity sweeps. When structural levels ar

Indicator v5 SMC / ICT / price actionSupport & resistance
Updated 1 year ago · Apache-2.0 GitHub repository of Market Structure with Inducements & Sweeps [LuxAlgo]

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