//@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.
The inputs you can change from the gear icon on the chart. Defaults are the author's.
Setting
Type
Default
Variable
Swing-Stärke
int
6
pivotLen
Max. gespeicherte Swings
int
80
maxSwings
Suchbereich letzte Swings
int
16
searchDepth
Max. gleichzeitige Patterns
int
1
maxConcurrentPatterns
Mindestqualität
int
60
minScore
ATR-Länge
int
14
atrLen
P5 max. innerhalb ATR
float
0.75
p5InsideTolAtr
P5 max. Overshoot ATR
float
1.0
p5OvershootTolAtr
Swing-Boundary-Toleranz ATR
float
0.35
swingBoundaryTolAtr
Bar-Boundary-Toleranz ATR
float
0.25
barBoundaryTolAtr
Max. Zielentfernung ATR-Multiple
float
8.0
maxTargetDistance
Mindest-Risk/Reward
float
1.5
minRR
Show all 35 settings
Setting
Type
Default
Variable
Reward als Pflichtfilter
bool
false
requireReward
Invalidierungs-Puffer ATR
float
0.5
invalidationAtr
Dynamische 1-3-Invalidierung (Bars)
int
10
dynamicInvalidationBars
Ablaufpuffer nach ETA (Bars)
int
10
expiryBufferBars
Max. Zwischen-Swing / Leg
float
0.50
maxLegPurityRatio
Invalidierung nur per Close (aus = Intrabar High/Low)
bool
true
invalidateOnClose
Abgeschlossene/ersetzte Patterns behalten
bool
false
showOldPatterns
Interne ZigZag-Struktur zeichnen
bool
true
drawZigzag
Kandidaten anzeigen
bool
true
showCandidate
Punktlabels anzeigen
bool
true
showLabels
Score-Label anzeigen
bool
true
showScoreLabel
Invalidierung anzeigen
bool
false
showInvalidation
ETA anzeigen
bool
true
showETA
Linien-Überstand (Bars)
int
10
lineAheadBars
Bullisch
color
color.lime
bullCol
Bärisch
color
color.red
bearCol
Nebenlinien
color
color.gray
weakCol
Kandidat
color
color.new(color.orange, 0)
candidateCol
Score-Breakdown Debug-Tabelle
bool
false
showDebug
Kandidat-Mindestscore (Watch)
int
25
candidateThreshold
Pine-Logs aktivieren (Fehlersuche)
bool
false
enableLogging
Dashboard anzeigen
bool
true
showDashboard
Alerts nur bei bestätigter Kerze (Bar Close)
bool
true
alertsOnBarClose
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.
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
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
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
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
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
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