Indicator · Pine Script v6 · On the chart

Wolfe Wave Scanner Pro [WavesUnchained]

by casoon

This overlay indicator scans confirmed swing pivots for non-overlapping five-point Wolfe Wave wedges.

View on GitHub

GitHub stars9on the repository
Pine versionv6current
Lines of code1,198
Settings35inputs
LicenceMIT
Last update3 days ago27 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,198 lines of Pine Script v6 from indicators/market_structure/wolfe_wave_scanner_pro/wolfe_wave_scanner_pro.pine, published under MIT. Keep the author's credit and licence lines when you reuse it.

wolfe_wave_scanner_pro.pine
//@version=6
// ============================================================================
// Wolfe Wave Scanner Pro [WavesUnchained]
// Version: 4.15
// Author: WavesUnchained
// Build: 2026-10-02 00:00:00
// ============================================================================
// Searches a depth window of confirmed swings for every non-overlapping
// valid 5-point Wolfe Wave wedge (not just the single highest-scoring one),
// projects each one's dynamic EPA target, and tracks each independently until
// the target is hit, the setup invalidates, or its ETA lifecycle expires.
//
// Features:
//   ✓ Best-of-window swing search across non-consecutive combinations, not
//     just the literal last 5 swings
//   ✓ Tracks multiple simultaneously valid, non-overlapping patterns at once
//     (each with its own drawings and invalidation state), not just one
//   ✓ Continuous 0-100 score: contraction, channel traversal,
//     apex, directional P5-zone, risk/reward, timing, symmetry, leg purity
//   ✓ EPA target projection from line 1-4 at the 1-3/2-4 apex time
//   ✓ ACTIVE → PARTIAL/TARGET/INVALIDATED/EXPIRED lifecycle + alerts
//   ✓ Near-miss candidate marker and score-breakdown debug table
//
// ============================================================================

indicator("Wolfe Wave 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(16, "Suchbereich letzte Swings", minval=5, maxval=24)
maxConcurrentPatterns = input.int(1, "Max. gleichzeitige Patterns", minval=1, maxval=8)

minScore          = input.int(60, "Mindestqualität", minval=0, maxval=100)
atrLen            = input.int(14, "ATR-Länge")
p5InsideTolAtr    = input.float(0.75, "P5 max. innerhalb ATR", minval=0.1, maxval=2.0, step=0.05)
p5OvershootTolAtr = input.float(1.0, "P5 max. Overshoot ATR", minval=0.1, maxval=3.0, step=0.05)
swingBoundaryTolAtr = input.float(0.35, "Swing-Boundary-Toleranz ATR", minval=0.05, maxval=2.0, step=0.05)
barBoundaryTolAtr = input.float(0.25, "Bar-Boundary-Toleranz ATR", minval=0.05, maxval=2.0, step=0.05)
maxTargetDistance = input.float(8.0, "Max. Zielentfernung ATR-Multiple", minval=0.5, step=0.25)
minRR             = input.float(1.5, "Mindest-Risk/Reward", minval=0.25, maxval=10.0, step=0.25)
requireReward     = input.bool(false, "Reward als Pflichtfilter")
invalidationAtr   = input.float(0.5, "Invalidierungs-Puffer ATR", minval=0.1, maxval=3.0, step=0.1)
dynamicInvalidationBars = input.int(10, "Dynamische 1-3-Invalidierung (Bars)", minval=0, maxval=100)
expiryBufferBars  = input.int(10, "Ablaufpuffer nach ETA (Bars)", minval=0, maxval=200)
maxLegPurityRatio = input.float(0.50, "Max. Zwischen-Swing / Leg", minval=0.10, maxval=1.0, step=0.05)
invalidateOnClose = input.bool(true, "Invalidierung nur per Close (aus = Intrabar High/Low)")

showOldPatterns   = input.bool(false, "Abgeschlossene/ersetzte Patterns behalten")
drawZigzag        = input.bool(true, "Interne ZigZag-Struktur zeichnen")
showCandidate     = input.bool(true, "Kandidaten anzeigen")
showLabels        = input.bool(true, "Punktlabels anzeigen")
showScoreLabel    = input.bool(true, "Score-Label anzeigen")
showInvalidation  = input.bool(false, "Invalidierung anzeigen")
showETA           = input.bool(true, "ETA anzeigen")
lineAheadBars     = input.int(10, "Linien-Überstand (Bars)", minval=1, maxval=50)

bullCol           = input.color(color.lime, "Bullisch")
bearCol           = input.color(color.red, "Bärisch")
weakCol           = input.color(color.gray, "Nebenlinien")
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)
max_bars_back(atr, 2000)

// 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
    bool bull
    int score
    int x1
    int x2
    int x3
    int x4
    int x5
    float y1
    float y2
    float y3
    float y4
    float y5
    float targetNow
    float targetETA
    float invalidate
    float patternAtr
    float riskReward
    float legPurityRatio
    float contractionRatio
    float widthStartAtr
    float widthMidAtr
    float widthEndAtr
    bool sameDirectionOk
    bool slopesComparableOk
    float slopeRatio
    float p4WidthAtr
    float channelWidthAt5Atr
    float traversal45
    float p5OffsetAtr
    int purityOffenderX
    int purityOffenderLeg
    float swingBoundaryViolationAtr
    int swingBoundaryViolationX
    float barBoundaryViolationAtr
    int barBoundaryViolationX
    int etaBar
    bool structureOk
    bool contractionOk
    bool channelOk
    bool etaOk
    bool p5ZoneOk
    bool rewardOk
    bool timingOk
    bool symmetryOk
    bool legPurityOk
    bool boundaryHoldOk
    bool barsInsideOk
    int structureScore
    int contractionScore
    int channelScore
    int etaScore
    int p5Score
    int rewardScore
    int timingScore
    int symmetryScore
    int purityScore

// 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
    float invalidateLevel
    line[] objLines
    label[] objLabels
    line invalidationLine
    string state
    bool partialHit
    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 — 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, false, 0,
         na, na, na, na, na,
         na, na, na, na, na,
         na, na, na,
         na, na, na,
         na, na, na, na,
         false, false, na,
         na, na, na, na,
         na, 0, 0.0, na, 0.0, na, na,
         false, false, false, false, false, false, false, false, false, false, true,
         0, 0, 0, 0, 0, 0, 0, 0, 0)

// Single best-in-window candidate — kept for the dashboard, debug table and
// near-miss candidate marker. Independent of `trackedPatterns` below, which
// holds every distinct, non-overlapping VALID pattern found at once.
var Pattern best = emptyPattern()

var trackedPatterns = array.new<TrackedPattern>()

var table dash = table.new(position.top_right, 2, 7,
     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("WWSP " + msg)

// Draws into a caller-supplied array instead of a single global one, so each
// tracked pattern can own (and later delete) exactly its own objects.
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_label(label[] arr, int x, float y, string txt, color col, style) =>
    if showLabels
        lb = label.new(x, y, txt, style=style, color=col, textcolor=color.white, size=size.small)
        array.push(arr, lb)

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_intersect_x(int ax1, float ay1, int ax2, float ay2, int bx1, float by1, int bx2, float by2) =>
    m1 = f_slope(ax1, ay1, ax2, ay2)
    m2 = f_slope(bx1, by1, bx2, by2)
    b1 = ay1 - m1 * ax1
    b2 = by1 - m2 * bx1
    na(m1) or na(m2) or math.abs(m1 - m2) < 0.0000001 ? na : int(math.round((b2 - b1) / (m1 - m2)))

// Checks EVERY bar between points 1 and 5 (not just the chosen swings) against
// both boundaries — same principle as `boundaryHoldOk` above, ported from a
// reference indicator (Wedge and Flag Finder by Trendoscope) that validates
// every bar's high/low, not just registered swings. Deliberately called ONCE
// per i5 on only the single best-scoring combination found for it (see the
// search loop below), never per combination — the combinatorial search
// already evaluates tens of thousands of (i1,i2,i3,i4) combinations per i5,
// so an O(bars) scan on every one of those would be prohibitively expensive.
f_bars_inside_metrics(Pattern p) =>
    ok = true
    maxViolationPrice = 0.0
    int offenderX = na
    for i = p.x1 to p.x5
        lvl13 = f_price(p.x1, p.y1, p.x3, p.y3, i)
        lvl24 = f_price(p.x2, p.y2, p.x4, p.y4, i)
        barHigh = high[bar_index - i]
        barLow = low[bar_index - i]
        tolPrice = p.patternAtr * barBoundaryTolAtr
        // P5 is deliberately allowed to test or overshoot line 1-3 within
        // its dedicated directional P5 zone. Applying the smaller generic
        // bar tolerance to the P5 candle contradicted that gate and rejected
        // a valid overshoot a second time. The opposite 2-4 wall remains
        // protected on P5 like on every other bar.
        violation13 = i == p.x5 ? 0.0 : p.bull ? math.max(lvl13 - tolPrice - barLow, 0.0) : math.max(barHigh - lvl13 - tolPrice, 0.0)
        violation24 = p.bull ? math.max(barHigh - lvl24 - tolPrice, 0.0) : math.max(lvl24 - tolPrice - barLow, 0.0)
        violationPrice = math.max(violation13, violation24)
        if violationPrice > 0
            ok := false
            if violationPrice > maxViolationPrice
                maxViolationPrice := violationPrice
                offenderX := i
    maxViolationAtr = p.patternAtr > 0 ? maxViolationPrice / p.patternAtr : 1000000.0
    [ok, maxViolationAtr, offenderX]

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)

// Overlap is checked on the [x4,x5] completion leg only, not the full
// [x1,x5] span. A Wolfe wave's point 1 can sit anywhere within the last
// `searchDepth` swings, so two genuinely different completions (different,
// well-separated point 5s) often still share the same early swings for
// points 1-2 — comparing the full range let one wide, high-scoring pattern
// block every other candidate in the window until it invalidated, even once
// a completely separate, more current wave had already formed. Direction
// matters too: a bull and a bear pattern occupying the same bars are two
// different scenarios, not competitors for one slot.
//
// A strict [x4,x5] interval check still let 2-3 same-direction patterns
// stack up right next to each other: each one's own completion leg can be
// narrow enough to not literally overlap its neighbor's while all of them
// still visually crowd the same small stretch of chart (confirmed from a
// chart example — three "Bear Wolfe" patterns a few bars apart from each
// other, none pairwise overlapping by the strict definition). A margin
// proportional to each pattern's own width — a third of the larger
// pattern's total span — closes that gap: patterns still need genuine
// separation to coexist, scaled to how big the wave itself is, instead of
// a fixed bar count that would be too tight for large waves and too loose
// for small ones.
f_overlaps(Pattern a, Pattern b) =>
    marginA = (a.x5 - a.x1) / 3
    marginB = (b.x5 - b.x1) / 3
    margin = math.max(marginA, marginB)
    a.bull == b.bull and math.max(a.x4 - margin, b.x4 - margin) <= math.min(a.x5 + margin, b.x5 + margin)

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` patterns that
// happen not to invalidate for a long stretch permanently fill every slot,
// silently discarding every new, better candidate found afterward (observed
// in logs: `best.valid=true` kept firing for over a year with zero new
// PATTERN FIRED/REPLACED events once all slots were occupied).
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's Bias/EPA/Invalidation reflect an actual tracked, live pattern
// instead of the raw search candidate, which can be a lower-scoring near-miss
// (or a different pattern entirely) than what's really being tracked.
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))

// 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. Checks every pair, keeping only
// the higher-scoring one of each same-direction, overlapping pair.
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 not showOldPatterns
                    f_delete_tracked(tp)
                array.remove(trackedPatterns, i)

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

f_eval(Swing p1, Swing p2, Swing p3, Swing p4, Swing p5, int i1, int i2, int i3, int i4, int i5) =>
    x1 = p1.x
    x2 = p2.x
    x3 = p3.x
    x4 = p4.x
    x5 = p5.x

    y1 = p1.y
    y2 = p2.y
    y3 = p3.y
    y4 = p4.y
    y5 = p5.y

    atrOffset = bar_index - x5
    patternAtrRaw = atr[atrOffset]
    patternAtr = na(patternAtrRaw) ? atr : patternAtrRaw

    bullSeq = p1.dir == -1 and p2.dir == 1 and p3.dir == -1 and p4.dir == 1 and p5.dir == -1
    bearSeq = p1.dir == 1 and p2.dir == -1 and p3.dir == 1 and p4.dir == -1 and p5.dir == 1

    // Core, non-negotiable shape: same-direction points trend towards the
    // reversal (lows declining for bull, highs rising for bear, across points
    // 1-3-5). Point 4's exact placement inside the 1-2 channel is a quality
    // nicety, not part of "is this even a Wolfe-shaped wave" — scored
    // separately as channelOk so one strict sub-check can't zero out the
    // whole structure gate.
    bullStructure = bullSeq and y3 < y1 and y5 < y3
    bearStructure = bearSeq and y3 > y1 and y5 > y3

    isBull = bullStructure
    isBear = bearStructure

    bullChannel = bullSeq and y4 > y1 and y4 < y2
    bearChannel = bearSeq and y4 < y1 and y4 > y2
    channelOk = bullChannel or bearChannel

    // Directional P5 location: positive means the classic slight overshoot
    // beyond line 1-3; negative means point 5 stayed inside the wedge.
    line13At5 = f_price(x1, y1, x3, y3, x5)
    p5Offset = bullSeq ? line13At5 - y5 : bearSeq ? y5 - line13At5 : 0.0
    p5OffsetAtr = patternAtr > 0 ? p5Offset / patternAtr : 1000000.0
    p5ZoneOk = (bullSeq or bearSeq) and p5OffsetAtr >= -p5InsideTolAtr and p5OffsetAtr <= p5OvershootTolAtr
    p5Quality = 0.0
    if p5OffsetAtr >= 0
        p5Quality := p5OffsetAtr <= 0.50 ? 1.0 : 1.0 - f_clamp01((p5OffsetAtr - 0.50) / math.max(p5OvershootTolAtr - 0.50, 0.000001))
    else
        p5Quality := p5OffsetAtr >= -0.25 ? 0.80 + 0.20 * f_clamp01((p5OffsetAtr + 0.25) / 0.25) : 0.80 * (1.0 - f_clamp01((-p5OffsetAtr - 0.25) / math.max(p5InsideTolAtr - 0.25, 0.000001)))

    line24At1 = f_price(x2, y2, x4, y4, x1)
    line24At3 = f_price(x2, y2, x4, y4, x3)
    line24At5 = f_price(x2, y2, x4, y4, x5)

    // Signed channel widths make an inverted/crossed wedge explicitly
    // invalid instead of rescuing it through abs().
    widthStart = bullSeq ? line24At1 - y1 : bearSeq ? y1 - line24At1 : -1.0
    widthMid   = bullSeq ? line24At3 - y3 : bearSeq ? y3 - line24At3 : -1.0
    widthEnd   = bullSeq ? line24At5 - y5 : bearSeq ? y5 - line24At5 : -1.0

    // Endpoints-only used to just require "somewhat narrower", which let
    // channels that bulge outward around points 3/4 (widen, then barely
    // re-narrow at the very end) still pass. Now requires a real contraction
    // ratio (>=30%) AND monotonic narrowing through the midpoint, so a wedge
    // that widens in the middle before snapping back can't sneak through.
    contractionRatio = widthStart > 0 ? widthEnd / widthStart : 1000000.0
    widthStartAtr = patternAtr > 0 ? widthStart / patternAtr : 1000000.0
    widthMidAtr = patternAtr > 0 ? widthMid / patternAtr : 1000000.0
    widthEndAtr = patternAtr > 0 ? widthEnd / patternAtr : 1000000.0
    contraction = widthStart > patternAtr * 0.5 and widthMid > 0 and widthEnd >= 0 and widthMid <= widthStart and widthEnd <= widthMid and contractionRatio <= 0.70
    contractionQuality = widthStart > 0 ? f_clamp01((1.0 - contractionRatio) / 0.30) : 0.0

    // Compares the two actual drawn/extended boundaries (1-3 and 2-4), not
    // 1-5 (which is no longer drawn at all). Same-sign alone doesn't stop one
    // boundary being near-flat while the other is near-vertical — that still
    // "converges" mathematically but never looks like a real wedge, so the
    // slope magnitudes must also be within a 3x ratio of each other.
    m13 = f_slope(x1, y1, x3, y3)
    m24 = f_slope(x2, y2, x4, y4)
    sameDirection = not na(m13) and not na(m24) and math.sign(m13) == math.sign(m24)

    slopeRatio = not na(m13) and not na(m24) and math.abs(m24) > 0.0000001 ? math.abs(m13) / math.abs(m24) : na
    slopesComparable = not na(slopeRatio) and slopeRatio >= 0.3 and slopeRatio <= 3.0

    // Point 4 must be a meaningful opposite-boundary auction, and the P4→P5
    // leg must cross most of the remaining channel rather than ending deep
    // inside it.
    line13At4 = f_price(x1, y1, x3, y3, x4)
    p4Width = bullSeq ? y4 - line13At4 : bearSeq ? line13At4 - y4 : -1.0
    channelWidthAt5 = bullSeq ? line24At5 - line13At5 : bearSeq ? line13At5 - line24At5 : -1.0
    p4WidthAtr = patternAtr > 0 ? p4Width / patternAtr : 1000000.0
    channelWidthAt5Atr = patternAtr > 0 ? channelWidthAt5 / patternAtr : 1000000.0
    p5PositionFrom13 = channelWidthAt5 > 0 ? (bullSeq ? (y5 - line13At5) / channelWidthAt5 : (line13At5 - y5) / channelWidthAt5) : 0.5
    traversal45 = f_clamp01(math.abs(1.0 - p5PositionFrom13))
    channelOk := channelOk and p4Width >= patternAtr * 0.50 and channelWidthAt5 > 0 and traversal45 >= 0.60
    channelQuality = (bullChannel or bearChannel) ? 0.40 + 0.30 * f_clamp01(p4Width / math.max(patternAtr, 0.000001)) + 0.30 * traversal45 : 0.0

    xi = f_intersect_x(x1, y1, x3, y3, x2, y2, x4, y4)
    widthBars = x5 - x1
    maxForwardBars = widthBars * 2
    etaOk = not na(xi) and xi > x5 and xi <= x5 + maxForwardBars
    etaDistanceRatio = not na(xi) and widthBars > 0 ? float(xi - x5) / float(widthBars) : 1000000.0
    etaQuality = etaOk ? f_clamp01(1.0 - math.abs(etaDistanceRatio - 0.75) / 1.25) : 0.0

    // Always bounded, even when xi isn't: near-parallel (but not exactly
    // parallel) 1-3/2-4 lines can put the raw intersection absurdly far from
    // x5 (or negative) — that crashes line.new() with "too far from current
    // bar" once drawn. etaOk still grades whether the TRUE intersection was
    // sane; this only controls what actually gets drawn/measured.
    xiBounded = etaOk ? xi : x5 + maxForwardBars

    targetNow = f_price(x1, y1, x4, y4, x5)

    // Classic definition: EPA is the price on line 1-4 at the apex time.
    targetETA = f_price(x1, y1, x4, y4, xiBounded)

    invalidate = isBull ? math.min(y5, line13At5) - patternAtr * invalidationAtr : isBear ? math.max(y5, line13At5) + patternAtr * invalidationAtr : na
    risk = not na(invalidate) ? math.abs(y5 - invalidate) : 0.0
    reward = math.abs(targetNow - y5)
    rewardDirectionOk = (bullSeq and targetNow > y5) or (bearSeq and targetNow < y5)
    targetDistanceOk = reward <= patternAtr * maxTargetDistance
    riskReward = risk > 0 ? reward / risk : 0.0
    rewardOk = rewardDirectionOk and targetDistanceOk and riskReward >= minRR
    rewardQuality = rewardDirectionOk and targetDistanceOk ? f_clamp01(riskReward / 2.0) : 0.0

    t12 = x2 - x1
    t23 = x3 - x2
    t34 = x4 - x3
    t45 = x5 - x4

    // Bounding only t45 against the sum of the other three missed the case
    // where a MIDDLE leg (e.g. 3-4) alone eats up more than half the whole
    // pattern's duration — t45 can still look "in proportion" to the sum
    // while one earlier leg completely dominates it. No single leg may
    // exceed 40% of the total pattern time, on top of the existing bound.
    totalTime = math.max(1, t12 + t23 + t34 + t45)
    maxLeg = math.max(math.max(t12, t23), math.max(t34, t45))
    noSingleLegDominates = float(maxLeg) / float(totalTime) <= 0.4

    timing = t12 > 0 and t23 > 0 and t34 > 0 and t45 > 0 and t45 <= (t12 + t23 + t34) * 1.5 and noSingleLegDominates

    symmetryRaw  = math.abs(t12 - t34) + math.abs(t23 - t45)
    symmetryBase = math.max(1, t12 + t23 + t34 + t45)
    symmetryRatio = float(symmetryRaw) / float(symmetryBase)
    symmetryOk   = symmetryRatio < 0.45

    // Leg purity penalizes combinatorial selections that skip a countermove
    // almost as large as the selected leg itself. Residual is measured from
    // each selected leg's straight path and normalized by that leg amplitude.
    maxSkippedLegRatio = 0.0
    int purityOffenderX = na
    purityOffenderLeg = 0
    if i5 > i1 + 1
        for k = i1 + 1 to i5 - 1
            if k != i2 and k != i3 and k != i4
                s = array.get(swings, k)
                legX1 = k < i2 ? x1 : k < i3 ? x2 : k < i4 ? x3 : x4
                legY1 = k < i2 ? y1 : k < i3 ? y2 : k < i4 ? y3 : y4
                legX2 = k < i2 ? x2 : k < i3 ? x3 : k < i4 ? x4 : x5
                legY2 = k < i2 ? y2 : k < i3 ? y3 : k < i4 ? y4 : y5
                legAmplitude = math.abs(legY2 - legY1)
                skippedRatio = legAmplitude > 0 ? math.abs(s.y - f_price(legX1, legY1, legX2, legY2, s.x)) / legAmplitude : 1000000.0
                if skippedRatio > maxSkippedLegRatio
                    maxSkippedLegRatio := skippedRatio
                    purityOffenderX := s.x
                    purityOffenderLeg := k < i2 ? 1 : k < i3 ? 2 : k < i4 ? 3 : 4
    legPurityOk = maxSkippedLegRatio <= maxLegPurityRatio
    purityQuality = 1.0 - f_clamp01(maxSkippedLegRatio / maxLegPurityRatio)

    structureOk   = isBull or isBear
    contractionOk = contraction and sameDirection and slopesComparable

    // A real Wolfe wave only tests the 1-3 boundary once, at point 5, and the channel
    // needs to hold on BOTH sides — the 2-4 boundary is just as much a wall as 1-3, it
    // just never gets its own "test" the way point 5 tests 1-3. Any swing in between —
    // chosen or skipped by the combinatorial search — that already breaches either
    // boundary by more than its dedicated swing tolerance is a premature
    // channel break, not a wedge. The 1-3 case was chart-confirmed first (a violent
    // plunge-then-recovery between points 3 and 4); checking only that boundary missed
    // the mirror case on the 2-4 side, which the point selection alone never catches
    // either, since only the five chosen swings were ever compared to each other.
    boundaryHoldOk = true
    swingTolPrice = patternAtr * swingBoundaryTolAtr
    maxSwingViolationPrice = 0.0
    int swingBoundaryViolationX = na
    if i5 > i1 + 1
        for k = i1 + 1 to i5 - 1
            s = array.get(swings, k)
            lvl13 = f_price(x1, y1, x3, y3, s.x)
            lvl24 = f_price(x2, y2, x4, y4, s.x)
            violation13 = bullSeq ? math.max(lvl13 - swingTolPrice - s.y, 0.0) : bearSeq ? math.max(s.y - lvl13 - swingTolPrice, 0.0) : 0.0
            violation24 = bullSeq ? math.max(s.y - lvl24 - swingTolPrice, 0.0) : bearSeq ? math.max(lvl24 - swingTolPrice - s.y, 0.0) : 0.0
            violationPrice = math.max(violation13, violation24)
            if violationPrice > 0
                boundaryHoldOk := false
                if violationPrice > maxSwingViolationPrice
                    maxSwingViolationPrice := violationPrice
                    swingBoundaryViolationX := s.x
    swingBoundaryViolationAtr = patternAtr > 0 ? maxSwingViolationPrice / patternAtr : 1000000.0

    structureScore = structureOk ? 20 : 0
    contractionScore = int(math.round(15.0 * (contractionOk ? contractionQuality : contractionQuality * 0.50)))
    channelScore = int(math.round(10.0 * f_clamp01(channelQuality)))
    etaScore = int(math.round(10.0 * etaQuality))
    p5Score = int(math.round(15.0 * (p5ZoneOk ? f_clamp01(p5Quality) : 0.0)))
    rewardScore = int(math.round(15.0 * rewardQuality))
    maxLegShare = float(maxLeg) / float(totalTime)
    timingQuality = timing ? f_clamp01((0.50 - maxLegShare) / 0.25) : 0.0
    timingScore = int(math.round(5.0 * timingQuality))
    symmetryScore = int(math.round(5.0 * (1.0 - f_clamp01(symmetryRatio / 0.45))))
    purityScore = int(math.round(5.0 * purityQuality))
    // The structure component is constant (20) for every pattern that passes
    // the structureOk 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 = contractionScore + channelScore + etaScore + p5Score + rewardScore + timingScore + symmetryScore + purityScore
    score = int(math.round(float(rawScore) * 100.0 / 80.0))

    // valid requires an actual bull/bear structure, unbroken boundaries, a
    // directional point-5 test within its dedicated inside/overshoot limits,
    // leg purity, meaningful P4→P5 traversal, AND a wedge apex that
    // actually converges ahead of point 5 — not just the seq-agnostic terms
    // (contraction/timing/symmetry) reaching the score alone. p5ZoneOk and etaOk were
    // previously scored but not gated, so two distinct bad cases could still validate:
    // (1) a point 5 that blew straight through the boundary by several ATRs, and
    // (2) an apex that had already converged at/before point 5 (etaOk false) — whose
    // fallback projection (x5 + 2x pattern width) then extrapolated the same, already-
    // converged slopes further out and went deeply negative in price, drawing wildly
    // wrong lines shooting off into empty space (chart-confirmed: apex fallback landed
    // at a negative price). A wedge that doesn't converge ahead of point 5 isn't a
    // completed Wolfe wave, regardless of how well the five points otherwise score.
    Pattern.new(
         score >= minScore and structureOk and contractionOk and channelOk and boundaryHoldOk and legPurityOk and p5ZoneOk and etaOk and (not requireReward or rewardOk),
         isBull,
         score,
         x1, x2, x3, x4, x5,
         y1, y2, y3, y4, y5,
         targetNow,
         targetETA,
         invalidate,
         patternAtr,
         riskReward,
         maxSkippedLegRatio,
         contractionRatio,
         widthStartAtr,
         widthMidAtr,
         widthEndAtr,
         sameDirection,
         slopesComparable,
         slopeRatio,
         p4WidthAtr,
         channelWidthAt5Atr,
         traversal45,
         p5OffsetAtr,
         purityOffenderX,
         purityOffenderLeg,
         swingBoundaryViolationAtr,
         swingBoundaryViolationX,
         0.0,
         na,
         xiBounded,
         structureOk, contractionOk, channelOk, etaOk, p5ZoneOk, rewardOk, timing, symmetryOk, legPurityOk, boundaryHoldOk, true,
         structureScore, contractionScore, channelScore, etaScore, p5Score, rewardScore, timingScore, symmetryScore, purityScore)

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

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

    col = p.bull ? bullCol : bearCol
    p5Style = p.bull ? label.style_label_up : label.style_label_down

    // The two real channel boundaries — 1-3 and 2-4 — stop exactly at the
    // apex (etaBar) instead of extending indefinitely: past that point
    // there's nothing left to show, the channel has already "closed".
    apexY13 = f_price(p.x1, p.y1, p.x3, p.y3, p.etaBar)
    apexY24 = f_price(p.x2, p.y2, p.x4, p.y4, p.etaBar)
    f_line(objLines, p.x1, p.y1, p.etaBar, apexY13, col, line.style_solid, 2)
    f_line(objLines, p.x2, p.y2, p.etaBar, apexY24, col, line.style_solid, 2)

    if drawZigzag
        f_line(objLines, p.x1, p.y1, p.x2, p.y2, weakCol, line.style_dotted, 1)
        f_line(objLines, p.x2, p.y2, p.x3, p.y3, weakCol, line.style_dotted, 1)
        f_line(objLines, p.x3, p.y3, p.x4, p.y4, weakCol, line.style_dotted, 1)
        f_line(objLines, p.x4, p.y4, p.x5, p.y5, weakCol, line.style_dotted, 1)

    // The 1-4 "trigger line" — visibly passing through points 1 and 4 —
    // drawn up to the apex bar, ending exactly at (etaBar, targetETA).
    // Colored bull/bear (like the wedge itself) and thinner so it reads as a
    // secondary reference.
    f_line(objLines, p.x1, p.y1, p.etaBar, p.targetETA, col, line.style_dashed, 1)

    // The vertical marker connects the red lines' own crossing price
    // (apexY13) directly to the 1-4 line's actual endpoint (targetETA) — not
    // y5, which can be far closer to the apex than targetETA is.
    if showETA
        f_line(objLines, p.etaBar, apexY13, p.etaBar, p.targetETA, col, line.style_dashed, 1)

    // Ends a little past the current bar instead of extend.right, which would
    // draw the level all the way to the right edge of the chart. Advanced bar
    // by bar in the invalidation-tracking loop below while the pattern stays
    // live, so it keeps pace with the chart without ever spanning it.
    line invalidationLine = na
    if showInvalidation
        invalidationLine := f_line(objLines, p.x5, p.invalidate, bar_index + lineAheadBars, p.invalidate, color.new(col, 20), line.style_dashed, 1)

    f_label(objLabels, p.x1, p.y1, "1", weakCol, p.bull ? label.style_label_up : label.style_label_down)
    f_label(objLabels, p.x2, p.y2, "2", weakCol, p.bull ? label.style_label_down : label.style_label_up)
    f_label(objLabels, p.x3, p.y3, "3", weakCol, p.bull ? label.style_label_up : label.style_label_down)
    f_label(objLabels, p.x4, p.y4, "4", weakCol, p.bull ? label.style_label_down : label.style_label_up)
    f_label(objLabels, p.x5, p.y5, "5", col, p5Style)

    if showScoreLabel
        txt = (p.bull ? "Bull Wolfe" : "Bear Wolfe") + "\nScore " + str.tostring(p.score) + "\nRR " + str.tostring(p.riskReward, "#.##") + "\nEPA@ETA " + str.tostring(p.targetETA, format.mintick)
        lb = label.new(p.x5, p.y5, txt, style=p5Style, color=col, textcolor=color.white)
        array.push(objLabels, lb)

    TrackedPattern.new(p, p.invalidate, objLines, objLabels, invalidationLine, "ACTIVE", false, 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)))

// ============================================================================
// Best Pattern Search (only re-run when a new swing has actually formed)
// ============================================================================
// Point 5 is NOT pinned to the freshest swing — a Wolfe wave is often already
// 2-10 swings old by the time it completes. Every possible point-5 anchor
// within the depth window is tried; for each, points 1-4 are searched
// combinatorially: since `swings` stores strictly alternating highs/lows,
// any pick of 3 same-direction points (1,3,5) and 2 opposite-direction points
// (2,4) in order is a valid alternating chain — not just the literal
// previous 4 swings. Every i5's own best-scoring valid combination is kept,
// not just the single overall best, so two genuinely separate Wolfe waves in
// the same window can both surface.

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

newBullPattern = false
newBearPattern = false

if newSwing and sz < 5
    f_log("SEARCH SKIPPED sz=" + str.tostring(sz) + " (<5, waiting for more swings)")

if newSwing and sz >= 5
    start = math.max(0, sz - searchDepth)
    perI5Best = array.new<Pattern>()
    localBest = emptyPattern()
    evalCount = 0
    structHits = 0

    for i5 = start + 4 to sz - 1
        dir5 = array.get(swings, i5).dir

        sameDirIdx = array.new_int()
        oppDirIdx  = array.new_int()
        for k = start to i5 - 1
            if array.get(swings, k).dir == dir5
                array.push(sameDirIdx, k)
            else
                array.push(oppDirIdx, k)

        nSame = array.size(sameDirIdx)
        nOpp  = array.size(oppDirIdx)

        localBestForI5 = emptyPattern()
        localBestRawForI5 = 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 small
        // nSame/nOpp — or the last a1 iteration below — would otherwise
        // trigger.
        if nSame >= 2 and nOpp >= 2
            for a1 = 0 to nSame - 2
                i1 = array.get(sameDirIdx, a1)
                for a3 = a1 + 1 to nSame - 1
                    i3 = array.get(sameDirIdx, a3)
                    for a2 = 0 to nOpp - 1
                        i2 = array.get(oppDirIdx, a2)
                        if i2 > i1 and i2 < i3
                            for a4 = 0 to nOpp - 1
                                i4 = array.get(oppDirIdx, a4)
                                if i4 > i3 and i4 < i5
                                    candidate = f_eval(
                                         array.get(swings, i1),
                                         array.get(swings, i2),
                                         array.get(swings, i3),
                                         array.get(swings, i4),
                                         array.get(swings, i5),
                                         i1, i2, i3, i4, i5)
                                    evalCount += 1
                                    structHits += candidate.structureOk ? 1 : 0
                                    if candidate.score > localBestRawForI5.score
                                        localBestRawForI5 := candidate
                                    // A higher-scoring shape that already
                                    // fails structural gates must not hide a
                                    // slightly lower-scoring valid setup for
                                    // the same point-5 anchor.
                                    if candidate.valid and candidate.score > localBestForI5.score
                                        localBestForI5 := candidate

        if localBestForI5.score == 0
            localBestForI5 := localBestRawForI5

        // Run only once per i5, on the single winning combination — see
        // f_bars_inside_metrics' own comment for why this can't run per
        // combination. boundaryHoldOk above only checked the intermediate
        // SWINGS, so a bar between two swings could still poke through the
        // channel without ever registering as a swing itself.
        if localBestForI5.structureOk
            [barsInsideOk, barBoundaryViolationAtr, barBoundaryViolationX] = f_bars_inside_metrics(localBestForI5)
            localBestForI5.barsInsideOk := barsInsideOk
            localBestForI5.barBoundaryViolationAtr := barBoundaryViolationAtr
            localBestForI5.barBoundaryViolationX := barBoundaryViolationX
            if not barsInsideOk
                localBestForI5.valid := false

        // Global best must see the exact same post-containment candidate that
        // is stored/tracked. Updating it inside the combination loop left the
        // dashboard showing a pre-validation winner.
        if localBestForI5.score > localBest.score
            localBest := localBestForI5

        array.push(perI5Best, localBestForI5)

    best := localBest
    f_log("SEARCH sz=" + str.tostring(sz) + " evaluated=" + str.tostring(evalCount) + " structHits=" + str.tostring(structHits)
         + " best.score=" + str.tostring(best.score) + " structureOk=" + str.tostring(best.structureOk)
         + " bull=" + str.tostring(best.bull) + " valid=" + str.tostring(best.valid)
         + " p5Zone=" + str.tostring(best.p5ZoneOk) + " contraction=" + str.tostring(best.contractionOk)
         + " contractionRatio=" + str.tostring(best.contractionRatio, "#.###")
         + " widthAtr=" + str.tostring(best.widthStartAtr, "#.##") + "/" + str.tostring(best.widthMidAtr, "#.##") + "/" + str.tostring(best.widthEndAtr, "#.##")
         + " sameDirection=" + str.tostring(best.sameDirectionOk) + " slopesComparable=" + str.tostring(best.slopesComparableOk)
         + " slopeRatio=" + str.tostring(best.slopeRatio, "#.##")
         + " p4WidthAtr=" + str.tostring(best.p4WidthAtr, "#.##") + " channelWidthAt5Atr=" + str.tostring(best.channelWidthAt5Atr, "#.##")
         + " traversal45=" + str.tostring(best.traversal45, "#.##") + " p5OffsetAtr=" + str.tostring(best.p5OffsetAtr, "#.##")
         + " channel=" + str.tostring(best.channelOk) + " eta=" + str.tostring(best.etaOk)
         + " reward=" + str.tostring(best.rewardOk) + " timing=" + str.tostring(best.timingOk)
         + " symmetry=" + str.tostring(best.symmetryOk) + " legPurity=" + str.tostring(best.legPurityOk)
         + " purityRatio=" + str.tostring(best.legPurityRatio, "#.###")
         + " purityOffender=" + str.tostring(best.purityOffenderX) + " purityLeg=" + str.tostring(best.purityOffenderLeg)
         + " boundaryHold=" + str.tostring(best.boundaryHoldOk) + " swingViolationAtr=" + str.tostring(best.swingBoundaryViolationAtr, "#.##")
         + " swingViolationX=" + str.tostring(best.swingBoundaryViolationX) + " barsInside=" + str.tostring(best.barsInsideOk)
         + " barViolationAtr=" + str.tostring(best.barBoundaryViolationAtr, "#.##") + " barViolationX=" + str.tostring(best.barBoundaryViolationX))

    // Lightweight candidate wedge for the current best-in-window, redrawn on
    // every search regardless of whether it clears minScore — shows why a
    // near-miss didn't fire without waiting for a confirmed pattern.
    f_clear_candidate()
    if showCandidate and best.structureOk and best.score >= candidateThreshold and not best.valid
        // Same two boundaries a real pattern is drawn with (1-3 and 2-4) — the
        // candidate previously showed 1-5/2-4, so it looked like a different
        // shape than the pattern it was previewing.
        candidateLine13At5 = f_price(best.x1, best.y1, best.x3, best.y3, best.x5)
        candidateLine24At5 = f_price(best.x2, best.y2, best.x4, best.y4, best.x5)
        candidateReason = ""
        candidateReason := candidateReason + (not best.contractionOk ? "Kontraktion" : "")
        candidateReason := candidateReason + (not best.channelOk ? (candidateReason == "" ? "" : " · ") + "Channel" : "")
        candidateReason := candidateReason + (not best.etaOk ? (candidateReason == "" ? "" : " · ") + "Apex" : "")
        candidateReason := candidateReason + (not best.p5ZoneOk ? (candidateReason == "" ? "" : " · ") + "P5" : "")
        candidateReason := candidateReason + (not best.legPurityOk ? (candidateReason == "" ? "" : " · ") + "Purity" : "")
        candidateReason := candidateReason + (not best.boundaryHoldOk ? (candidateReason == "" ? "" : " · ") + "Swing-Boundary" : "")
        candidateReason := candidateReason + (not best.barsInsideOk ? (candidateReason == "" ? "" : " · ") + "Bar-Boundary" : "")
        candidateReason := candidateReason + (requireReward and not best.rewardOk ? (candidateReason == "" ? "" : " · ") + "Risk/Reward" : "")
        candidateReason := candidateReason == "" ? "Hard Gate" : candidateReason
        candLn1 = line.new(best.x1, best.y1, best.x5, candidateLine13At5, color=candidateCol, style=line.style_dashed, width=2)
        candLn2 = line.new(best.x2, best.y2, best.x5, candidateLine24At5, color=candidateCol, style=line.style_dashed, width=2)
        array.push(candidateLines, candLn1)
        array.push(candidateLines, candLn2)
        candidateLabelY = best.bull ? math.min(best.y5, candidateLine13At5) : math.max(best.y5, candidateLine13At5)
        candLabel = label.new(best.x5, candidateLabelY, "WATCH · " + (best.bull ? "Bullisch" : "Bärisch") + " · " + str.tostring(best.score) + "\nBlocker: " + candidateReason, style=best.bull ? label.style_label_up : label.style_label_down, color=candidateCol, textcolor=color.white, size=size.small)
        array.push(candidateLabels, candLabel)

    // Every distinct, non-overlapping valid point-5 anchor 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
    // pattern already tracked. Once the cap is full, a non-overlapping
    // candidate evicts the weakest currently tracked pattern if it scores
    // higher than that one — without this, the first `maxConcurrentPatterns`
    // patterns that happen not to invalidate for a while permanently occupy
    // every slot and silently block every better candidate found afterward.
    if array.size(perI5Best) > 0
        for i = 0 to array.size(perI5Best) - 1
            c = array.get(perI5Best, i)
            if c.valid
                // Same arrival gates as Triangle/Broadening Scanner Pro: P5 is
                // often several swings old when it is found, so a candidate can
                // already be past its ETA expiry, or already invalidated / at
                // its EPA target on the bar it is found. Firing it would only
                // emit "new pattern" together with an immediate end state.
                // Levels are evaluated exactly as in the lifecycle loop below.
                alreadyAged = bar_index > c.etaBar + expiryBufferBars
                float arrivalInvalidateLevel = c.invalidate
                if bar_index <= c.x5 + dynamicInvalidationBars
                    arrivalLine13 = f_price(c.x1, c.y1, c.x3, c.y3, bar_index)
                    arrivalInvalidateLevel := c.bull ? math.min(c.y5, arrivalLine13) - c.patternAtr * invalidationAtr : math.max(c.y5, arrivalLine13) + c.patternAtr * invalidationAtr
                bool arrivalInvalidated = false
                if invalidateOnClose
                    arrivalInvalidated := c.bull ? close < arrivalInvalidateLevel : close > arrivalInvalidateLevel
                else
                    arrivalInvalidated := c.bull ? low < arrivalInvalidateLevel : high > arrivalInvalidateLevel
                arrivalEpa = f_price(c.x1, c.y1, c.x4, c.y4, bar_index)
                arrivalTargetHit = c.bull ? high >= arrivalEpa : low <= arrivalEpa
                stillOpen = not arrivalInvalidated and not arrivalTargetHit
                if alreadyAged
                    f_log("PATTERN SKIPPED (already aged) bull=" + str.tostring(c.bull) + " score=" + str.tostring(c.score) + " x5=" + str.tostring(c.x5))
                else if not stillOpen
                    f_log("PATTERN SKIPPED (stillborn) bull=" + str.tostring(c.bull) + " score=" + str.tostring(c.score) + " x5=" + str.tostring(c.x5))
                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)
                            newBullPattern := newBullPattern or c.bull
                            newBearPattern := newBearPattern or not c.bull
                            f_log("PATTERN FIRED bull=" + str.tostring(c.bull) + " score=" + str.tostring(c.score)
                                 + " x1=" + str.tostring(c.x1) + " x5=" + str.tostring(c.x5))
                        else
                            weakestIdx = f_find_weakest_index()
                            weakest = array.get(trackedPatterns, weakestIdx)
                            if c.score > weakest.p.score
                                if not showOldPatterns
                                    f_delete_tracked(weakest)
                                newTp = f_draw_pattern(c)
                                array.set(trackedPatterns, weakestIdx, newTp)
                                newBullPattern := newBullPattern or c.bull
                                newBearPattern := newBearPattern or not c.bull
                                f_log("PATTERN EVICTED old_score=" + str.tostring(weakest.p.score) + " new bull=" + str.tostring(c.bull)
                                     + " score=" + str.tostring(c.score) + " x1=" + str.tostring(c.x1) + " x5=" + str.tostring(c.x5))
                    else
                        existing = array.get(trackedPatterns, overlapIdx)
                        if c.x5 != existing.p.x5 and c.score > existing.p.score
                            if not showOldPatterns
                                f_delete_tracked(existing)
                            newTp = f_draw_pattern(c)
                            array.set(trackedPatterns, overlapIdx, newTp)
                            newBullPattern := newBullPattern or c.bull
                            newBearPattern := newBearPattern or not c.bull
                            f_log("PATTERN REPLACED bull=" + str.tostring(c.bull) + " score=" + str.tostring(c.score)
                                 + " x1=" + str.tostring(c.x1) + " x5=" + str.tostring(c.x5))

    // 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) — otherwise several patterns can end up tracked
    // at once even though they visually occupy the same stretch.
    f_dedupe_overlaps()

// ============================================================================
// Pattern lifecycle tracking (every bar)
// ============================================================================

anyInvalidated = false
anyTargetHit = false
anyPartialSuccess = false
anyExpired = false

if array.size(trackedPatterns) > 0
    for i = array.size(trackedPatterns) - 1 to 0
        tp = array.get(trackedPatterns, i)

        // During the first bars after P5, invalidation follows the extended
        // 1-3 structure. Afterwards the last dynamic level remains frozen.
        if bar_index <= tp.p.x5 + dynamicInvalidationBars
            line13Now = f_price(tp.p.x1, tp.p.y1, tp.p.x3, tp.p.y3, bar_index)
            tp.invalidateLevel := tp.p.bull ? math.min(tp.p.y5, line13Now) - tp.p.patternAtr * invalidationAtr : math.max(tp.p.y5, line13Now) + tp.p.patternAtr * invalidationAtr

        invalidated = false
        if invalidateOnClose
            invalidated := tp.p.bull ? close < tp.invalidateLevel : close > tp.invalidateLevel
        else
            invalidated := tp.p.bull ? low < tp.invalidateLevel : high > tp.invalidateLevel

        // EPA is line 1-4 and therefore changes on every bar. targetETA is
        // retained only as the projected value at the wedge apex.
        epaNow = f_price(tp.p.x1, tp.p.y1, tp.p.x4, tp.p.y4, bar_index)
        targetHit = tp.p.bull ? high >= epaNow : low <= epaNow
        partialLevel = tp.p.y5 + (epaNow - tp.p.y5) * 0.50
        partialHitNow = not tp.partialHit and (tp.p.bull ? high >= partialLevel : low <= partialLevel)
        expired = bar_index > tp.p.etaBar + expiryBufferBars
        // One-bar grace period, same as Triangle/Broadening Scanner Pro: no
        // lifecycle event on the pattern's own creation bar, so "new pattern"
        // never coincides with Partial/Target/Invalidated/Expired.
        justCreated = bar_index <= tp.createdBar
        if justCreated
            invalidated := false
            targetHit := false
            partialHitNow := false
            expired := false

        if invalidated
            tp.state := "INVALIDATED"
            anyInvalidated := true
            f_log("INVALIDATED bull=" + str.tostring(tp.p.bull) + " level=" + str.tostring(tp.invalidateLevel, format.mintick))
            lb = label.new(bar_index, tp.invalidateLevel, "Invalidated",
                 style=tp.p.bull ? label.style_label_down : label.style_label_up,
                 color=color.new(color.gray, 20), textcolor=color.white, size=size.tiny)
            array.push(tp.objLabels, lb)
            if not showOldPatterns
                f_delete_tracked(tp)
            array.remove(trackedPatterns, i)
        else if targetHit
            tp.state := "TARGET_HIT"
            anyTargetHit := true
            f_log("TARGET HIT bull=" + str.tostring(tp.p.bull) + " epa=" + str.tostring(epaNow, format.mintick))
            lb = label.new(bar_index, epaNow, "EPA Target",
                 style=tp.p.bull ? label.style_label_up : label.style_label_down,
                 color=tp.p.bull ? bullCol : bearCol, textcolor=color.white, size=size.tiny)
            array.push(tp.objLabels, lb)
            if not showOldPatterns
                f_delete_tracked(tp)
            array.remove(trackedPatterns, i)
        else if expired
            tp.state := "EXPIRED"
            anyExpired := true
            f_log("EXPIRED bull=" + str.tostring(tp.p.bull) + " eta=" + str.tostring(tp.p.etaBar))
            lb = label.new(bar_index, close, "Expired",
                 style=label.style_label_left, color=color.new(color.gray, 20), textcolor=color.white, size=size.tiny)
            array.push(tp.objLabels, lb)
            if not showOldPatterns
                f_delete_tracked(tp)
            array.remove(trackedPatterns, i)
        else
            if partialHitNow
                tp.state := "PARTIAL_SUCCESS"
                tp.partialHit := true
                anyPartialSuccess := true
                f_log("PARTIAL bull=" + str.tostring(tp.p.bull) + " level=" + str.tostring(partialLevel, format.mintick))
                lb = label.new(bar_index, partialLevel, "Partial",
                     style=tp.p.bull ? label.style_label_up : label.style_label_down,
                     color=color.new(tp.p.bull ? bullCol : bearCol, 35), textcolor=color.white, size=size.tiny)
                array.push(tp.objLabels, lb)
            if not na(tp.invalidationLine)
                line.set_xy1(tp.invalidationLine, tp.p.x5, tp.invalidateLevel)
                line.set_xy2(tp.invalidationLine, bar_index + lineAheadBars, tp.invalidateLevel)

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

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

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

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

    // Bias/EPA/Invalidation reflect the best currently TRACKED pattern when one
    // exists, not the raw search candidate — `best` can be a lower-scoring
    // near-miss (or a different pattern entirely) than what's actually being
    // tracked, which made the dashboard look contradictory when a pattern was
    // active but `best` itself wasn't the one live on the chart.
    activeIdx = statusActive ? f_find_best_active_index() : -1
    // Without an active pattern a valid `best` is never "ready": a valid
    // candidate that passes the arrival gates is tracked the moment it is
    // found, so an untracked valid one is stillborn, aged or already resolved.
    // Only a near-miss (same rule as the chart's candidate marker) is WATCH.
    dashWatch = not statusActive and best.structureOk and best.score >= candidateThreshold and not best.valid
    Pattern dashPattern = emptyPattern()
    if statusActive
        dashPattern := array.get(trackedPatterns, activeIdx).p
    else if dashWatch
        dashPattern := best
    dashState = statusActive ? array.get(trackedPatterns, activeIdx).state : dashWatch ? "WATCH" : "SCAN"
    epaCurrent = statusActive ? f_price(dashPattern.x1, dashPattern.y1, dashPattern.x4, dashPattern.y4, bar_index) : dashPattern.targetNow
    invalidationCurrent = statusActive ? array.get(trackedPatterns, activeIdx).invalidateLevel : dashPattern.invalidate

    string biasTxt = dashPattern.structureOk ? (dashPattern.bull ? "Bullisch" : "Bärisch") : "—"
    color biasCol = dashPattern.bull ? color.new(#00c853, 0) : color.new(#d50000, 0)

    table.cell(dash, 0, 0, "Wolfe Engine", text_color=tc, bgcolor=hd, text_size=size.small)
    table.cell(dash, 1, 0, dashState == "PARTIAL_SUCCESS" ? "PARTIAL" : dashState, text_color=color.white, bgcolor=statusActive ? biasCol : 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, "Bias", text_color=tc, text_size=size.small)
    table.cell(dash, 1, 3, biasTxt, text_color=dashPattern.structureOk ? biasCol : tc, text_size=size.small)

    table.cell(dash, 0, 4, "EPA Current", text_color=tc, text_size=size.small)
    table.cell(dash, 1, 4, na(epaCurrent) ? "—" : str.tostring(epaCurrent, format.mintick), text_color=tc, text_size=size.small)

    table.cell(dash, 0, 5, "EPA @ ETA", text_color=tc, text_size=size.small)
    table.cell(dash, 1, 5, na(dashPattern.targetETA) ? "—" : str.tostring(dashPattern.targetETA, format.mintick), text_color=tc, text_size=size.small)

    table.cell(dash, 0, 6, "Invalidation", text_color=tc, text_size=size.small)
    table.cell(dash, 1, 6, na(invalidationCurrent) ? "—" : str.tostring(invalidationCurrent, format.mintick), 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, "Struktur (Gate)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 1, str.tostring(best.structureScore) + (best.structureOk ? " · OK" : " · GATE"), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 2, "Kontraktion (15)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 2, str.tostring(best.contractionScore) + (best.contractionOk ? " · OK" : " · GATE"), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 3, "Channel/Traversal (10)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 3, str.tostring(best.channelScore) + (best.channelOk ? " · OK" : " · GATE"), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 4, "Konvergenz/Apex (10)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 4, str.tostring(best.etaScore) + (best.etaOk ? " · OK" : " · GATE"), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 5, "P5-Zone (15)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 5, str.tostring(best.p5Score) + (best.p5ZoneOk ? " · OK" : " · GATE"), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 6, "Risk/Reward (15)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 6, str.tostring(best.rewardScore) + " · " + str.tostring(best.riskReward, "#.##"), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 7, "Timing (5)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 7, str.tostring(best.timingScore) + (best.timingOk ? " · OK" : ""), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 8, "Symmetrie (5)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 8, str.tostring(best.symmetryScore) + (best.symmetryOk ? " · OK" : ""), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 9, "Leg Purity (5)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 9, str.tostring(best.purityScore) + " · " + str.tostring(best.legPurityRatio, "#.##") + (best.legPurityOk ? " OK" : " GATE"), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 10, "Purity-Auslöser", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 10, "Leg " + str.tostring(best.purityOffenderLeg) + " @ " + str.tostring(best.purityOffenderX), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 11, "Swing Boundaries", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 11, best.boundaryHoldOk ? "OK" : str.tostring(best.swingBoundaryViolationAtr, "#.##") + " ATR @ " + str.tostring(best.swingBoundaryViolationX), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 12, "Bar Boundaries", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 12, best.barsInsideOk ? "OK" : str.tostring(best.barBoundaryViolationAtr, "#.##") + " ATR @ " + str.tostring(best.barBoundaryViolationX), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 13, "Breite S/M/E (ATR)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 13, str.tostring(best.widthStartAtr, "#.##") + "/" + str.tostring(best.widthMidAtr, "#.##") + "/" + str.tostring(best.widthEndAtr, "#.##"), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 14, "Kontr. / Slopes", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 14, str.tostring(best.contractionRatio, "#.##") + " · " + (best.sameDirectionOk ? "DIR OK" : "DIR GATE") + " · " + str.tostring(best.slopeRatio, "#.##") + (best.slopesComparableOk ? " OK" : " GATE"), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 15, "P4 / Kanal / Trav.", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 15, str.tostring(best.p4WidthAtr, "#.##") + "/" + str.tostring(best.channelWidthAt5Atr, "#.##") + " ATR · " + str.tostring(best.traversal45, "#.##") + (best.channelOk ? " OK" : " GATE"), text_color=tc, text_size=size.small)

    table.cell(dbgTable, 0, 16, "P5-Offset (ATR)", text_color=tc, text_size=size.small)
    table.cell(dbgTable, 1, 16, str.tostring(best.p5OffsetAtr, "#.##") + (best.p5ZoneOk ? " · OK" : " · GATE"), text_color=tc, text_size=size.small)

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

alertOk = not alertsOnBarClose or barstate.isconfirmed

alertcondition(alertOk and newBullPattern, "Bullische Wolfe Wave", "WWSP · BULL WOLFE · {{ticker}} {{interval}}")
alertcondition(alertOk and newBearPattern, "Bärische Wolfe Wave", "WWSP · BEAR WOLFE · {{ticker}} {{interval}}")
alertcondition(alertOk and anyPartialSuccess, "Wolfe Wave Partial Success", "WWSP · PARTIAL SUCCESS · {{ticker}} {{interval}}")
alertcondition(alertOk and anyTargetHit, "Wolfe Wave EPA erreicht", "WWSP · EPA TARGET HIT · {{ticker}} {{interval}}")
alertcondition(alertOk and anyInvalidated, "Wolfe Wave Invalidiert", "WWSP · INVALIDATED · {{ticker}} {{interval}}")
alertcondition(alertOk and anyExpired, "Wolfe Wave Abgelaufen", "WWSP · EXPIRED · {{ticker}} {{interval}}")

Overview

Wolfe Wave Scanner Pro [WavesUnchained] scores each candidate from 0 to 100 using contraction, channel traversal, apex, the fifth-point zone, risk/reward, timing, symmetry, and leg purity, then projects a dynamic estimated price at arrival target. It tracks active patterns until the target is reached, the setup invalidates, or the estimated arrival time expires, and can draw the zigzag, point labels, candidates, and invalidation levels. Swing length, search depth, ATR tolerances, minimum score, and minimum risk/reward change detection. It does not place trades.

Details

Published by

casoon on GitHub

Draws

On the price chart

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 Swingsint16searchDepth
Max. gleichzeitige Patternsint1maxConcurrentPatterns
Mindestqualitätint60minScore
ATR-Längeint14atrLen
P5 max. innerhalb ATRfloat0.75p5InsideTolAtr
P5 max. Overshoot ATRfloat1.0p5OvershootTolAtr
Swing-Boundary-Toleranz ATRfloat0.35swingBoundaryTolAtr
Bar-Boundary-Toleranz ATRfloat0.25barBoundaryTolAtr
Max. Zielentfernung ATR-Multiplefloat8.0maxTargetDistance
Mindest-Risk/Rewardfloat1.5minRR
Show all 35 settings
SettingTypeDefaultVariable
Reward als PflichtfilterboolfalserequireReward
Invalidierungs-Puffer ATRfloat0.5invalidationAtr
Dynamische 1-3-Invalidierung (Bars)int10dynamicInvalidationBars
Ablaufpuffer nach ETA (Bars)int10expiryBufferBars
Max. Zwischen-Swing / Legfloat0.50maxLegPurityRatio
Invalidierung nur per Close (aus = Intrabar High/Low)booltrueinvalidateOnClose
Abgeschlossene/ersetzte Patterns behaltenboolfalseshowOldPatterns
Interne ZigZag-Struktur zeichnenbooltruedrawZigzag
Kandidaten anzeigenbooltrueshowCandidate
Punktlabels anzeigenbooltrueshowLabels
Score-Label anzeigenbooltrueshowScoreLabel
Invalidierung anzeigenboolfalseshowInvalidation
ETA anzeigenbooltrueshowETA
Linien-Überstand (Bars)int10lineAheadBars
Bullischcolorcolor.limebullCol
Bärischcolorcolor.redbearCol
Nebenliniencolorcolor.grayweakCol
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

947 stars

Moving Average Channel plots separate simple moving averages for upper and lower bands on the price chart. It fills the space between bands with semi-transparent color and colors bars green above the upper band, red below the lower band, or gray in between. Us

Indicator v4 Moving averagesSupport & resistance
Updated 12 days ago · GPL-3.0 GitHub repository of Moving Average Channel

ICT Equal Highs and Lows

by ArunKBhaskar
575 stars

This overlay indicator finds pivot highs and lows on the price chart and compares the last two highs and the last two lows. When the percentage difference between a pair is below the Equal HL threshold, it treats them as equal levels and draws a box. Left and

Indicator v5 SMC / ICT / price actionSupport & resistance
Updated 1 year ago · MPL-2.0 GitHub repository of ICT Equal Highs and Lows
330 stars

This indicator monitors multiple cryptocurrency symbols for trading signals. It calculates and plots entry prices, take profit, and stop loss levels for each symbol including BTCUSDT, ETHUSDT, BNBUSDT and others. Price crossovers trigger buy, sell or close ale

Indicator v5 Support & resistanceScreeners & dashboards
Updated 2 months ago · None stated GitHub repository of Multi Alert Long/Short [Alorse]

This overlay indicator draws a fixed-range volume profile on the price chart, splitting traded activity into up and down volume at each price level. It also plots a sentiment profile, a developing point of control, and value-area high and low around a configur

Indicator v5 VolumeSupport & resistance
Updated 2 months ago · MIT GitHub repository of Volume Profile and Indicator by DGT

BZ-tracker

by Allen1212
303 stars

The BZ-tracker indicator marks ARK Invest buy and sell transactions on stock charts with labels. It plots green labels for purchases and red labels for sales, each annotated with a multiplier such as X1 or X2 to show the number of ARK ETFs trading the stock on

Indicator v4 Support & resistanceScreeners & dashboards
Updated 2 years ago · None stated GitHub repository of BZ-tracker

Function - Linear Regression

by ricardosantos79
261 stars

This indicator fits a linear regression through stored pivot highs and lows and draws the resulting channel on the price chart. A function returns predicted values, maximum and minimum deviations, and the average absolute deviation. Confirmed two-bar pivots up

Indicator v5 TrendSupport & resistance
Updated 4 years ago · None stated GitHub repository of Function - Linear Regression

More Support & resistance scripts

Related on ForexR

Trade Your Charts With a Regulated Broker

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

Compare brokers