//@version=6
// ============================================================================
// Triangle Compression Scanner Pro [WavesUnchained]
// Version: 1.15
// Author: WavesUnchained
// Build: 2026-10-02 00:00:00
// ============================================================================
// Slides a fixed-size window of confirmed swings across the search depth and
// fits two boundary lines (upper from the window's outer highs, lower from
// its outer lows) to find compressing triangles — symmetric, ascending, or
// descending. Every distinct, non-overlapping valid triangle is tracked
// independently until a confirmed breakout resolves it.
//
// Features:
// ✓ Sliding-window search (not a fixed point count like a harmonic pattern)
// across consecutive swing windows within the search depth
// ✓ Tracks multiple simultaneously valid, non-overlapping triangles at
// once, each with its own drawings and breakout state
// ✓ Weighted 0-100 score: traversal, realized compression, boundary
// reliability, apex maturity, duration, and current relevance
// ✓ Live breakout tracking (either direction resolves the pattern) + alert
// ✓ Near-miss candidate preview (Watch signal) for the best-in-window shape
// ✓ Score-breakdown debug table
//
// ============================================================================
indicator("Triangle Compression Scanner Pro [WavesUnchained]", overlay=true, max_lines_count=500, max_labels_count=500)
// ============================================================================
// Inputs
// ============================================================================
pivotLen = input.int(6, "Swing-Stärke", minval=2, maxval=30)
maxSwings = input.int(80, "Max. gespeicherte Swings", minval=20, maxval=200)
searchDepth = input.int(24, "Suchbereich letzte Swings", minval=8, maxval=60)
triangleWindow = input.int(8, "Swings pro Dreieck-Fenster", minval=6, maxval=20)
maxConcurrentPatterns = input.int(1, "Max. gleichzeitige Patterns", minval=1, maxval=15)
maxPatternAgeMult = input.float(1.5, "Max. Pattern-Alter (× Fensterbreite)", minval=1.0, step=0.5)
minScore = input.int(60, "Mindestqualität", minval=0, maxval=100)
atrLen = input.int(14, "ATR-Länge")
flatSlopeAtrTol = input.float(0.5, "Flat-Toleranz (ATR über Fensterbreite)", minval=0.05, step=0.05)
touchTolAtr = input.float(0.5, "Touch-Toleranz ATR", minval=0.05, step=0.05)
projectedCompressionTarget = input.float(0.75, "Ziel proj. Endbreite / Startbreite", minval=0.3, maxval=0.95, step=0.05)
minWidthAtr = input.float(1.0, "Mindest-Startbreite ATR", minval=0.2, step=0.1)
minTraversalRatio = input.float(0.55, "Min. Traversal pro Leg", minval=0.40, maxval=0.90, step=0.05)
maxRealizedCompressionRatio = input.float(0.85, "Max. realisierte Leg-Kompression", minval=0.40, maxval=1.0, step=0.05)
minAnchorSpanRatio = input.float(0.45, "Min. Anker-Span / Pattern-Dauer", minval=0.30, maxval=0.90, step=0.05)
maxMeanResidualAtr = input.float(1.0, "Max. mittleres Boundary-Residuum ATR", minval=0.25, maxval=3.0, step=0.25)
maxApexDistanceMult = input.float(1.25, "Max. Apex-Distanz (× Pattern-Dauer)", minval=0.25, maxval=3.0, step=0.25)
breakoutBufferAtr = input.float(0.15, "Breakout-Puffer ATR", minval=0.0, maxval=1.0, step=0.05)
showOldPatterns = input.bool(true, "Aufgelöste Patterns behalten")
showCandidate = input.bool(true, "Kandidaten anzeigen")
showLabels = input.bool(true, "Score-Label anzeigen")
lineAheadBars = input.int(10, "Linien-Überstand (Bars)", minval=1, maxval=50)
triangleCol = input.color(color.blue, "Dreieck (offen)")
bullCol = input.color(color.lime, "Bullischer Breakout")
bearCol = input.color(color.red, "Bärischer Breakout")
candidateCol = input.color(color.new(color.orange, 0), "Kandidat")
groupDebug = "Debug"
showDebug = input.bool(false, "Score-Breakdown Debug-Tabelle", group=groupDebug)
candidateThreshold = input.int(25, "Kandidat-Mindestscore (Watch)", minval=0, maxval=100, group=groupDebug)
enableLogging = input.bool(false, "Pine-Logs aktivieren (Fehlersuche)", group=groupDebug)
groupDash = "Dashboard"
showDashboard = input.bool(true, "Dashboard anzeigen", group=groupDash)
groupAlert = "Alerts"
alertsOnBarClose = input.bool(true, "Alerts nur bei bestätigter Kerze (Bar Close)", group=groupAlert)
atr = ta.atr(atrLen)
// The bar-by-bar containment check below indexes high/low up to
// searchDepth × pivotLen bars back, which can exceed Pine's default
// auto-detected historical buffer for a variable (non-constant) offset.
max_bars_back(high, 2000)
max_bars_back(low, 2000)
// ============================================================================
// Types
// ============================================================================
type Swing
int x
float y
int dir // +1 = High, -1 = Low
type Pattern
bool valid
string ttype // "Symmetrisch" | "Ascending" | "Descending" | "None"
int score
int xU1
int xU2
float yU1
float yU2
int xL1
int xL2
float yL1
float yL2
int windowStartX
int windowEndX
bool structureOk
bool pathOk
bool traversalOk
bool realizedCompressionOk
bool boundaryOk
bool apexOk
bool lineQualityOk
bool durationOk
bool barsInsideOk
int progressionMatches
int progressionTests
int longestTraversalRun
float averageTraversal
float upperAnchorSpanRatio
float lowerAnchorSpanRatio
float meanResidualAtr
bool swingInsideOk
bool distributionOk
bool flatSideOk
int upperZoneCount
int lowerZoneCount
float slopeRatio
float apexDistanceRatio
float apexProgress
float containmentRatio
int pathScore
int traversalScore
int compressionScore
int touchScore
int lineScore
int boundaryScore
int durationScore
int relevanceScore
// A tracked, currently-live pattern owns its own drawing objects so it can
// be cleaned up independently of every other tracked pattern.
type TrackedPattern
Pattern p
line[] objLines
label[] objLabels
int createdBar
// ============================================================================
// Storage
// ============================================================================
var swings = array.new<Swing>()
var candidateLines = array.new_line()
var candidateLabels = array.new_label()
// Tracks the newest swing's bar index, not the array LENGTH — once `swings`
// hits its `maxSwings` cap, every push is paired with a shift, so the length
// never changes again and a length-based "did a new swing form" check would
// freeze forever right when the cap is first reached.
var int lastSwingX = -1
emptyPattern() =>
Pattern.new(false, "None", 0, na, na, na, na, na, na, na, na, na, na,
false, false, false, false, false, false, false, false, false,
0, 0, 0, na, na, na, na, false, false, false, 0, 0, na, na, na, na,
0, 0, 0, 0, 0, 0, 0, 0)
// Single best-in-window candidate, regardless of whether it could ever
// actually be tracked — kept only for the debug table (explains why the
// overall best-scoring shape still isn't firing) and the near-miss
// candidate marker. Independent of `trackedPatterns` below, which holds
// every distinct, non-overlapping VALID triangle found at once.
var Pattern best = emptyPattern()
var trackedPatterns = array.new<TrackedPattern>()
// Resolved patterns move here only when showOldPatterns keeps their
// drawings around — kept purely so a later overlapping candidate can be
// compared against the score of the historical shape still on the chart,
// instead of silently drawing a second, lower-quality triangle on top of it.
var keptPatterns = array.new<TrackedPattern>()
var table dash = table.new(position.top_right, 2, 5,
bgcolor=color.new(color.white, 5),
border_color=color.new(color.gray, 60),
border_width=1,
frame_color=color.new(color.gray, 40),
frame_width=1)
var table dbgTable = table.new(position.bottom_right, 2, 17,
bgcolor=color.new(color.white, 5),
border_color=color.new(color.gray, 60),
border_width=1,
frame_color=color.new(color.gray, 40),
frame_width=1)
color tc = color.new(color.gray, 20)
color hd = color.new(color.gray, 90)
// ============================================================================
// Helpers
// ============================================================================
f_clear_candidate() =>
while array.size(candidateLines) > 0
line.delete(array.pop(candidateLines))
while array.size(candidateLabels) > 0
label.delete(array.pop(candidateLabels))
f_log(string msg) =>
if enableLogging
log.info("TCS " + msg)
f_line(line[] arr, int x1, float y1, int x2, float y2, color col, style, int width, ext = extend.none) =>
ln = line.new(x1, y1, x2, y2, color=col, style=style, width=width, extend=ext)
array.push(arr, ln)
ln
f_price(int x1, float y1, int x2, float y2, int x) =>
x2 == x1 ? y1 : y1 + (y2 - y1) * float(x - x1) / float(x2 - x1)
f_slope(int x1, float y1, int x2, float y2) =>
x2 == x1 ? na : (y2 - y1) / float(x2 - x1)
f_clamp01(float value) =>
math.min(math.max(value, 0.0), 1.0)
f_proximity_quality(float distanceAtr) =>
distanceAtr <= 0.25 ? 1.0 : distanceAtr >= 0.75 ? 0.0 : 1.0 - (distanceAtr - 0.25) / 0.50
// Picks the two REAL touch points (not a computed average) that best act as
// a boundary: the pair with the fewest other points violating it (poking
// through by more than `tolPrice` on the wrong side — above for an upper/
// resistance line, below for a lower/support one), tie-broken by the widest
// anchor span, the smallest mean residual, and finally the most touches.
// This is how a chartist actually draws
// a trendline — through real highs/lows that define the edge — not a
// least-squares fit, which pulls the line through the middle of the point
// cloud and can miss every real touch when a few points cluster together.
// O(n^3) over the window's own point count (n = nH or nL, capped by half of
// `triangleWindow`/`wedgeWindow`, so at most a few dozen), trivial at this
// scale. Requires at least 2 points; callers already guarantee this
// (`array.size(highX) >= 2 and array.size(lowX) >= 2`).
f_best_boundary(int[] xs, float[] ys, float tolPrice, bool isUpper) =>
n = array.size(xs)
bestI = 0
bestJ = n - 1
bestViolations = 1000000
bestTouches = -1
bestSpan = -1
bestMeanResidual = 1000000000.0
for i = 0 to n - 2
xi = array.get(xs, i)
yi = array.get(ys, i)
for j = i + 1 to n - 1
xj = array.get(xs, j)
yj = array.get(ys, j)
touches = 0
violations = 0
residualSum = 0.0
for k = 0 to n - 1
xk = array.get(xs, k)
yk = array.get(ys, k)
dist = yk - f_price(xi, yi, xj, yj, xk)
residualSum += math.abs(dist)
if math.abs(dist) <= tolPrice
touches += 1
else if isUpper and dist > tolPrice
violations += 1
else if not isUpper and dist < -tolPrice
violations += 1
span = xj - xi
meanResidual = residualSum / float(n)
better = violations < bestViolations or (violations == bestViolations and (span > bestSpan or (span == bestSpan and (meanResidual < bestMeanResidual or (meanResidual == bestMeanResidual and touches > bestTouches)))))
if better
bestViolations := violations
bestTouches := touches
bestSpan := span
bestMeanResidual := meanResidual
bestI := i
bestJ := j
[array.get(xs, bestI), array.get(ys, bestI), array.get(xs, bestJ), array.get(ys, bestJ)]
f_add_swing(int x, float y, int dir) =>
sz = array.size(swings)
if sz == 0
array.push(swings, Swing.new(x, y, dir))
else
last = array.get(swings, sz - 1)
if last.dir == dir
stronger = dir == 1 ? y > last.y : y < last.y
if stronger
array.set(swings, sz - 1, Swing.new(x, y, dir))
else
array.push(swings, Swing.new(x, y, dir))
while array.size(swings) > maxSwings
array.shift(swings)
// True interval overlap between two patterns' [windowStartX,windowEndX]
// ranges — keeps at most one tracked triangle per stretch of chart.
f_overlaps(Pattern a, Pattern b) =>
math.max(a.windowStartX, b.windowStartX) <= math.min(a.windowEndX, b.windowEndX)
f_find_overlap_index(Pattern p, TrackedPattern[] arr) =>
n = array.size(arr)
idx = -1
if n > 0
for i = 0 to n - 1
tp = array.get(arr, i)
if idx < 0 and f_overlaps(p, tp.p)
idx := i
idx
// Index of the lowest-scoring currently tracked pattern — used to make room
// for a new, non-overlapping, higher-scoring candidate once the cap is
// reached. Without this, the first `maxConcurrentPatterns` triangles that
// happen not to break out for a long stretch permanently fill every slot,
// silently discarding every new, better candidate found afterward (the same
// bug fixed in Wolfe Wave Scanner Pro's f_find_weakest_index).
f_find_weakest_index() =>
n = array.size(trackedPatterns)
idx = -1
minScoreSeen = 1000000
if n > 0
for i = 0 to n - 1
tp = array.get(trackedPatterns, i)
if tp.p.score < minScoreSeen
minScoreSeen := tp.p.score
idx := i
idx
// Index of the highest-scoring currently tracked pattern — used so the
// dashboard reflects an actual tracked, live pattern instead of the raw
// search candidate (same fix as Wolfe Wave Scanner Pro's
// f_find_best_active_index).
f_find_best_active_index() =>
n = array.size(trackedPatterns)
idx = -1
maxScoreSeen = -1
if n > 0
for i = 0 to n - 1
tp = array.get(trackedPatterns, i)
if tp.p.score > maxScoreSeen
maxScoreSeen := tp.p.score
idx := i
idx
// Deletes every drawing object owned by a tracked pattern. Arrays are
// reference types in Pine, so popping from tp.objLines/objLabels mutates
// the exact arrays the TrackedPattern struct already holds.
f_delete_tracked(TrackedPattern tp) =>
while array.size(tp.objLines) > 0
line.delete(array.pop(tp.objLines))
while array.size(tp.objLabels) > 0
label.delete(array.pop(tp.objLabels))
// Finalizes a still-open tracked pattern's lines into a bounded segment
// ending at the current bar (instead of drifting on extend.right forever)
// and moves it into keptPatterns, so a future overlapping candidate is still
// compared against its score. Shared by every path that displaces a
// still-open pattern while `showOldPatterns` is on: overlap replacement, cap
// eviction, breakout resolution, and overlap-chain dedupe.
f_finalize_and_keep(TrackedPattern tp) =>
if array.size(tp.objLines) >= 2
upperLine = array.get(tp.objLines, 0)
lowerLine = array.get(tp.objLines, 1)
upperNow = f_price(tp.p.xU1, tp.p.yU1, tp.p.xU2, tp.p.yU2, bar_index)
lowerNow = f_price(tp.p.xL1, tp.p.yL1, tp.p.xL2, tp.p.yL2, bar_index)
line.set_extend(upperLine, extend.none)
line.set_extend(lowerLine, extend.none)
line.set_xy2(upperLine, bar_index, upperNow)
line.set_xy2(lowerLine, bar_index, lowerNow)
array.push(keptPatterns, tp)
// Cleans up same-direction overlap chains the pairwise insertion check can
// miss: if A overlaps B and B overlaps C, but A and C don't directly overlap
// each other, all three could end up tracked at once even though they
// visually occupy the same stretch (same fix as Wolfe Wave Scanner Pro's
// f_dedupe_overlaps). Checks every pair, keeping only the higher-scoring one.
f_dedupe_overlaps() =>
n = array.size(trackedPatterns)
if n > 1
keep = array.new_bool(n, true)
for i = 0 to n - 2
for j = i + 1 to n - 1
tpI = array.get(trackedPatterns, i)
tpJ = array.get(trackedPatterns, j)
if array.get(keep, i) and array.get(keep, j) and f_overlaps(tpI.p, tpJ.p)
if tpI.p.score >= tpJ.p.score
array.set(keep, j, false)
else
array.set(keep, i, false)
for i = n - 1 to 0
if not array.get(keep, i)
tp = array.get(trackedPatterns, i)
if showOldPatterns
f_finalize_and_keep(tp)
else
f_delete_tracked(tp)
array.remove(trackedPatterns, i)
// ============================================================================
// Scoring Engine
// ============================================================================
f_eval_triangle(int[] pathX, float[] pathY, int[] pathDir, int[] highX, float[] highY, int[] lowX, float[] lowY, int windowStartX, int windowEndX) =>
nH = array.size(highX)
nL = array.size(lowX)
nPath = array.size(pathX)
windowBars = windowEndX - windowStartX
touchTolPrice = touchTolAtr * atr
// Boundaries are the two real touch points that best act as a resistance
// (upper) / support (lower) line — fewest violations, broad anchor span,
// and small residuals — not a least-squares average through the cloud.
[xU1, yU1, xU2, yU2] = f_best_boundary(highX, highY, touchTolPrice, true)
[xL1, yL1, xL2, yL2] = f_best_boundary(lowX, lowY, touchTolPrice, false)
slopeU = f_slope(xU1, yU1, xU2, yU2)
slopeL = f_slope(xL1, yL1, xL2, yL2)
// "Flat" is defined relative to how much the line would move across the
// whole window, in ATR terms — not an absolute slope value, so it scales
// sensibly across symbols/timeframes.
flatTolPrice = windowBars > 0 ? flatSlopeAtrTol * atr / windowBars : flatSlopeAtrTol * atr
upperFlat = not na(slopeU) and math.abs(slopeU) < flatTolPrice
lowerFlat = not na(slopeL) and math.abs(slopeL) < flatTolPrice
upperFalling = not na(slopeU) and slopeU < 0 and not upperFlat
lowerRising = not na(slopeL) and slopeL > 0 and not lowerFlat
isSymmetric = upperFalling and lowerRising
isAscending = upperFlat and lowerRising
isDescending = upperFalling and lowerFlat
ttype = isSymmetric ? "Symmetrisch" : isAscending ? "Ascending" : isDescending ? "Descending" : "None"
structureOk = isSymmetric or isAscending or isDescending
midX = int(math.round((windowStartX + windowEndX) / 2.0))
upperAtStart = f_price(xU1, yU1, xU2, yU2, windowStartX)
lowerAtStart = f_price(xL1, yL1, xL2, yL2, windowStartX)
upperAtMid = f_price(xU1, yU1, xU2, yU2, midX)
lowerAtMid = f_price(xL1, yL1, xL2, yL2, midX)
upperAtEnd = f_price(xU1, yU1, xU2, yU2, windowEndX)
lowerAtEnd = f_price(xL1, yL1, xL2, yL2, windowEndX)
// Signed, not abs(): an inverted channel is invalid geometry, not a
// positive width that should be rescued by an absolute value.
widthStart = upperAtStart - lowerAtStart
widthMid = upperAtMid - lowerAtMid
widthEnd = upperAtEnd - lowerAtEnd
projectedCompressionRatio = widthStart > 0 ? widthEnd / widthStart : 1000000.0
compressionGeometryOk = widthStart > minWidthAtr * atr and widthMid > 0 and widthEnd > 0 and widthMid <= widthStart and widthEnd <= widthMid and projectedCompressionRatio <= projectedCompressionTarget
// The same-side extremum sequence must confirm the triangle family. The
// flat side of ascending/descending triangles is validated separately by
// repeated boundary contacts instead of an artificial monotonic rule.
alternatingPath = true
if nPath > 1
for i = 1 to nPath - 1
if array.get(pathDir, i) == array.get(pathDir, i - 1)
alternatingPath := false
progressionTests = 0
progressionMatches = 0
if (isSymmetric or isDescending) and nH > 1
for i = 1 to nH - 1
progressionTests += 1
if array.get(highY, i) < array.get(highY, i - 1)
progressionMatches += 1
if (isSymmetric or isAscending) and nL > 1
for i = 1 to nL - 1
progressionTests += 1
if array.get(lowY, i) > array.get(lowY, i - 1)
progressionMatches += 1
progressionRatio = progressionTests > 0 ? float(progressionMatches) / float(progressionTests) : 0.0
pathOk = structureOk and alternatingPath and nPath >= 6 and nH >= 3 and nL >= 3 and progressionTests >= 2 and progressionMatches == progressionTests
pathScore = structureOk and alternatingPath ? int(math.round(20.0 * progressionRatio)) : 0
// Boundary-to-boundary utilization: a generous outer envelope no longer
// qualifies merely because it contains the price. At least three
// consecutive alternating legs must traverse most of the local width.
traversalSum = 0.0
traversalCount = 0
qualifyingTraversals = 0
currentTraversalRun = 0
longestTraversalRun = 0
legAmplitudes = array.new_float()
if nPath > 1
for i = 1 to nPath - 1
x1 = array.get(pathX, i - 1)
y1 = array.get(pathY, i - 1)
x2 = array.get(pathX, i)
y2 = array.get(pathY, i)
upper1 = f_price(xU1, yU1, xU2, yU2, x1)
lower1 = f_price(xL1, yL1, xL2, yL2, x1)
upper2 = f_price(xU1, yU1, xU2, yU2, x2)
lower2 = f_price(xL1, yL1, xL2, yL2, x2)
width1 = upper1 - lower1
width2 = upper2 - lower2
pos1 = width1 > 0 ? (y1 - lower1) / width1 : 0.5
pos2 = width2 > 0 ? (y2 - lower2) / width2 : 0.5
traversal = f_clamp01(math.abs(pos2 - pos1))
traversalSum += traversal
traversalCount += 1
array.push(legAmplitudes, math.abs(y2 - y1))
if traversal >= minTraversalRatio
qualifyingTraversals += 1
currentTraversalRun += 1
longestTraversalRun := math.max(longestTraversalRun, currentTraversalRun)
else
currentTraversalRun := 0
averageTraversal = traversalCount > 0 ? traversalSum / float(traversalCount) : 0.0
qualifyingTraversalRatio = traversalCount > 0 ? float(qualifyingTraversals) / float(traversalCount) : 0.0
traversalOk = longestTraversalRun >= 3
traversalScore = int(math.round(20.0 * (0.65 * f_clamp01(averageTraversal / minTraversalRatio) + 0.35 * qualifyingTraversalRatio)))
// Realized compression compares actual price legs. Same-direction legs
// are compared with one another so impulse/retracement alternation does
// not create a false failure.
legCount = array.size(legAmplitudes)
firstLegAverage = legCount >= 2 ? (array.get(legAmplitudes, 0) + array.get(legAmplitudes, 1)) / 2.0 : 0.0
lastLegAverage = legCount >= 2 ? (array.get(legAmplitudes, legCount - 2) + array.get(legAmplitudes, legCount - 1)) / 2.0 : 0.0
realizedCompressionRatio = firstLegAverage > 0 ? lastLegAverage / firstLegAverage : 1000000.0
legShrinkTests = 0
legShrinkMatches = 0
float previousUpLeg = na
float previousDownLeg = na
if legCount > 0
for i = 0 to legCount - 1
legAmplitude = array.get(legAmplitudes, i)
legEndsAtHigh = array.get(pathDir, i + 1) == 1
previousSameDirection = legEndsAtHigh ? previousUpLeg : previousDownLeg
if not na(previousSameDirection)
legShrinkTests += 1
if legAmplitude <= previousSameDirection * 1.05
legShrinkMatches += 1
if legEndsAtHigh
previousUpLeg := legAmplitude
else
previousDownLeg := legAmplitude
legShrinkRatio = legShrinkTests > 0 ? float(legShrinkMatches) / float(legShrinkTests) : 0.0
realizedCompressionOk = compressionGeometryOk and legCount >= 5 and realizedCompressionRatio <= maxRealizedCompressionRatio and legShrinkRatio >= 0.60
realizedRatioQuality = maxRealizedCompressionRatio < 1.0 ? f_clamp01((1.0 - realizedCompressionRatio) / (1.0 - maxRealizedCompressionRatio)) : (realizedCompressionRatio <= 1.0 ? 1.0 : 0.0)
projectedRatioQuality = f_clamp01((1.0 - projectedCompressionRatio) / (1.0 - projectedCompressionTarget))
compressionScore = int(math.round(15.0 * (0.70 * realizedRatioQuality + 0.20 * legShrinkRatio + 0.10 * projectedRatioQuality)))
upperAnchorSpanRatio = windowBars > 0 ? float(xU2 - xU1) / float(windowBars) : 0.0
lowerAnchorSpanRatio = windowBars > 0 ? float(xL2 - xL1) / float(windowBars) : 0.0
extraHighTouches = 0
highResidualSum = 0.0
highViolations = 0
upperEarly = false
upperMiddle = false
upperLate = false
proximitySum = 0.0
proximityCount = 0
if nH > 0
for i = 0 to nH - 1
hx = array.get(highX, i)
hy = array.get(highY, i)
lineY = f_price(xU1, yU1, xU2, yU2, hx)
dist = hy - lineY
absDist = math.abs(dist)
highResidualSum += absDist
isAnchor = hx == xU1 or hx == xU2
if not isAnchor
proximitySum += f_proximity_quality(atr > 0 ? absDist / atr : 1000000.0)
proximityCount += 1
if absDist <= touchTolPrice
if not isAnchor
extraHighTouches += 1
positionRatio = windowBars > 0 ? float(hx - windowStartX) / float(windowBars) : 0.0
if positionRatio < 0.333333
upperEarly := true
else if positionRatio < 0.666667
upperMiddle := true
else
upperLate := true
else if dist > touchTolPrice
highViolations += 1
extraLowTouches = 0
lowResidualSum = 0.0
lowViolations = 0
lowerEarly = false
lowerMiddle = false
lowerLate = false
if nL > 0
for i = 0 to nL - 1
lx = array.get(lowX, i)
ly = array.get(lowY, i)
lineY = f_price(xL1, yL1, xL2, yL2, lx)
dist = ly - lineY
absDist = math.abs(dist)
lowResidualSum += absDist
isAnchor = lx == xL1 or lx == xL2
if not isAnchor
proximitySum += f_proximity_quality(atr > 0 ? absDist / atr : 1000000.0)
proximityCount += 1
if absDist <= touchTolPrice
if not isAnchor
extraLowTouches += 1
positionRatio = windowBars > 0 ? float(lx - windowStartX) / float(windowBars) : 0.0
if positionRatio < 0.333333
lowerEarly := true
else if positionRatio < 0.666667
lowerMiddle := true
else
lowerLate := true
else if dist < -touchTolPrice
lowViolations += 1
touchScore = int(math.round(15.0 * (proximityCount > 0 ? proximitySum / float(proximityCount) : 0.0)))
upperZoneCount = (upperEarly ? 1 : 0) + (upperMiddle ? 1 : 0) + (upperLate ? 1 : 0)
lowerZoneCount = (lowerEarly ? 1 : 0) + (lowerMiddle ? 1 : 0) + (lowerLate ? 1 : 0)
distributionOk = upperZoneCount >= 2 and lowerZoneCount >= 2 and (upperLate or lowerLate)
flatSideOk = isAscending ? extraHighTouches >= 1 : isDescending ? extraLowTouches >= 1 : true
pointCount = nH + nL
meanResidual = pointCount > 0 ? (highResidualSum + lowResidualSum) / float(pointCount) : atr * 1000000.0
meanResidualAtr = atr > 0 ? meanResidual / atr : 1000000.0
swingInsideOk = highViolations == 0 and lowViolations == 0
boundaryOk = upperAnchorSpanRatio >= minAnchorSpanRatio and lowerAnchorSpanRatio >= minAnchorSpanRatio and meanResidualAtr <= maxMeanResidualAtr and swingInsideOk and distributionOk and flatSideOk
// For a symmetric triangle, neither side should be wildly steeper than
// the other (a near-flat side paired with a near-vertical one never
// looks like a real converging triangle, even if it technically
// qualifies) — same 3x-ratio reasoning as Wolfe Wave Scanner.
slopeRatio = isSymmetric and not na(slopeU) and not na(slopeL) and math.abs(slopeL) > 0.0000001 ? math.abs(slopeU) / math.abs(slopeL) : na
lineQualityOk = isSymmetric ? (not na(slopeRatio) and slopeRatio >= 0.3 and slopeRatio <= 3.0) : structureOk
durationOk = windowBars >= pivotLen * 3
durationScore = int(math.round(5.0 * f_clamp01(float(windowBars) / float(pivotLen * 6))))
// For two converging straight lines the apex is their intersection. It
// must still lie ahead, but not so far away that the fitted boundaries
// are effectively unrelated trends. Maturity contributes continuously.
convergenceSlope = slopeU - slopeL
apexX = not na(convergenceSlope) and convergenceSlope < 0 ? float(windowStartX) - widthStart / convergenceSlope : na
apexDistance = not na(apexX) ? apexX - float(windowEndX) : -1.0
apexDistanceRatio = windowBars > 0 ? apexDistance / float(windowBars) : -1.0
apexProgress = not na(apexX) and apexX > float(windowStartX) ? float(windowBars) / (apexX - float(windowStartX)) : 0.0
apexOk = not na(apexX) and apexDistance > 0 and apexDistance <= maxApexDistanceMult * float(math.max(windowBars, 1))
apexQuality = f_clamp01(1.0 - math.abs(apexProgress - 0.65) / 0.35)
anchorQuality = (f_clamp01(upperAnchorSpanRatio) + f_clamp01(lowerAnchorSpanRatio)) / 2.0
boundaryScore = int(math.round(10.0 * (0.60 * anchorQuality + 0.40 * apexQuality)))
// Bar containment is soft. Residuals, distributed contacts, and traversal
// decide whether both boundaries are functional; an isolated wick no
// longer discards an otherwise coherent triangle outright.
barsInsideCount = 0
evaluatedBars = 0
for i = windowStartX to windowEndX
upperAt = f_price(xU1, yU1, xU2, yU2, i)
lowerAt = f_price(xL1, yL1, xL2, yL2, i)
barHigh = high[bar_index - i]
barLow = low[bar_index - i]
evaluatedBars += 1
if barHigh <= upperAt + touchTolPrice and barLow >= lowerAt - touchTolPrice
barsInsideCount += 1
containmentRatio = evaluatedBars > 0 ? float(barsInsideCount) / float(evaluatedBars) : 0.0
barsInsideOk = containmentRatio >= 0.90
residualQuality = 1.0 - f_clamp01(meanResidualAtr / maxMeanResidualAtr)
violationRatio = pointCount > 0 ? float(highViolations + lowViolations) / float(pointCount) : 1.0
distributionQuality = (float(upperZoneCount) + float(lowerZoneCount)) / 6.0
lineScore = int(math.round(10.0 * (0.45 * residualQuality + 0.35 * containmentRatio + 0.10 * (1.0 - violationRatio) + 0.10 * distributionQuality)))
maxRelevantAge = maxPatternAgeMult * float(math.max(windowBars, 1))
candidateAge = float(math.max(bar_index - windowEndX, 0))
relevanceScore = int(math.round(5.0 * (maxRelevantAge > 0 ? 1.0 - f_clamp01(candidateAge / maxRelevantAge) : 0.0)))
// The swing-path component is constant (20) for every pattern that passes
// the pathOk gate, so it is excluded from the score: it stays a gate and a
// debug readout only. The remaining components (80 points) are scaled to
// 0-100 so the score range and Mindestqualität keep their meaning.
rawScore = traversalScore + compressionScore + touchScore + lineScore + boundaryScore + durationScore + relevanceScore
score = int(math.round(float(rawScore) * 100.0 / 80.0))
hardGatesOk = structureOk and pathOk and traversalOk and realizedCompressionOk and boundaryOk and apexOk and lineQualityOk and durationOk
Pattern.new(
score >= minScore and hardGatesOk,
ttype,
score,
xU1, xU2, yU1, yU2,
xL1, xL2, yL1, yL2,
windowStartX, windowEndX,
structureOk, pathOk, traversalOk, realizedCompressionOk, boundaryOk, apexOk, lineQualityOk, durationOk, barsInsideOk,
progressionMatches, progressionTests, longestTraversalRun, averageTraversal,
upperAnchorSpanRatio, lowerAnchorSpanRatio, meanResidualAtr,
swingInsideOk, distributionOk, flatSideOk, upperZoneCount, lowerZoneCount,
slopeRatio, apexDistanceRatio, apexProgress, containmentRatio,
pathScore, traversalScore, compressionScore, touchScore, lineScore, boundaryScore, durationScore, relevanceScore)
// ============================================================================
// Pattern drawing — builds one self-contained TrackedPattern
// ============================================================================
// A pattern's boundary is a straight line fitted on its own window; extending
// it forever as the chart advances means extrapolating that fixed slope over
// an ever-growing distance, which inevitably drifts into nonsensical prices
// the longer a pattern stays untouched (chart- and log-confirmed on
// Broadening Wedge Scanner Pro: with no cap, ~100% of tracked wedges ended
// via ATR-drift, essentially never via a real breakout). Capping the
// extension to one more window-width past the window's own end bounds how
// far the line can ever be extrapolated, by construction — instead of
// drawing it forever and then reactively expiring it once the damage is
// already visible.
f_line_cap_x(Pattern p) =>
p.windowEndX + (p.windowEndX - p.windowStartX)
f_draw_pattern(Pattern p) =>
objLines = array.new_line()
objLabels = array.new_label()
// Ends a little past the current bar instead of extend.right, which would
// draw the boundary all the way to the right edge of the chart. Advanced
// bar by bar in the breakout-tracking loop below while the pattern stays
// open (and short of the fixed cap), so it keeps pace with the chart
// without ever spanning it or extrapolating indefinitely.
aheadX = math.min(bar_index + lineAheadBars, f_line_cap_x(p))
f_line(objLines, p.xU1, p.yU1, aheadX, f_price(p.xU1, p.yU1, p.xU2, p.yU2, aheadX), triangleCol, line.style_solid, 2)
f_line(objLines, p.xL1, p.yL1, aheadX, f_price(p.xL1, p.yL1, p.xL2, p.yL2, aheadX), triangleCol, line.style_solid, 2)
if showLabels
txt = p.ttype + " Triangle\nScore " + str.tostring(p.score)
lb = label.new(p.windowEndX, p.yU2, txt, style=label.style_label_down, color=triangleCol, textcolor=color.white, size=size.small)
array.push(objLabels, lb)
TrackedPattern.new(p, objLines, objLabels, bar_index)
// ============================================================================
// Swing Detection
// ============================================================================
ph = ta.pivothigh(high, pivotLen, pivotLen)
pl = ta.pivotlow(low, pivotLen, pivotLen)
if not na(ph)
f_add_swing(bar_index - pivotLen, ph, 1)
f_log("SWING dir=H x=" + str.tostring(bar_index - pivotLen) + " y=" + str.tostring(ph, format.mintick) + " count=" + str.tostring(array.size(swings)))
if not na(pl)
f_add_swing(bar_index - pivotLen, pl, -1)
f_log("SWING dir=L x=" + str.tostring(bar_index - pivotLen) + " y=" + str.tostring(pl, format.mintick) + " count=" + str.tostring(array.size(swings)))
// ============================================================================
// Search (only re-run when a new swing has actually formed)
// ============================================================================
// Slides a fixed-size window across the search depth; for each window end,
// the outer highs/lows within it define the two boundary lines. Every
// window end contributes one fixed-size candidate, so two genuinely separate
// triangles in the search depth can both surface.
sz = array.size(swings)
currentSwingX = sz > 0 ? array.get(swings, sz - 1).x : -1
newSwing = currentSwingX != lastSwingX
lastSwingX := currentSwingX
newTriangleFound = false
if newSwing and sz < triangleWindow
f_log("SEARCH SKIPPED sz=" + str.tostring(sz) + " (<" + str.tostring(triangleWindow) + ")")
if newSwing and sz >= triangleWindow
start = math.max(0, sz - searchDepth)
candidatesPerWindowEnd = array.new<Pattern>()
localBest = emptyPattern()
// Guarded explicitly: Pine's `for i = A to B` silently reverses (and can
// step on an out-of-bounds index) whenever A > B — which happens here if
// `searchDepth` is set smaller than `triangleWindow`, leaving no room for
// a single full window inside the search range.
firstEnd = math.max(start + triangleWindow - 1, triangleWindow - 1)
if firstEnd <= sz - 1
for end = firstEnd to sz - 1
winStart = end - triangleWindow + 1
highX = array.new_int()
highY = array.new_float()
lowX = array.new_int()
lowY = array.new_float()
pathX = array.new_int()
pathY = array.new_float()
pathDir = array.new_int()
for k = winStart to end
s = array.get(swings, k)
array.push(pathX, s.x)
array.push(pathY, s.y)
array.push(pathDir, s.dir)
if s.dir == 1
array.push(highX, s.x)
array.push(highY, s.y)
else
array.push(lowX, s.x)
array.push(lowY, s.y)
candidate = emptyPattern()
if array.size(highX) >= 2 and array.size(lowX) >= 2
windowStartX = array.get(swings, winStart).x
windowEndX = array.get(swings, end).x
candidate := f_eval_triangle(pathX, pathY, pathDir, highX, highY, lowX, lowY, windowStartX, windowEndX)
if candidate.score > localBest.score
localBest := candidate
array.push(candidatesPerWindowEnd, candidate)
best := localBest
f_log("SEARCH sz=" + str.tostring(sz) + " best.score=" + str.tostring(best.score) + " type=" + best.ttype + " valid=" + str.tostring(best.valid)
+ " structure=" + str.tostring(best.structureOk) + " path=" + str.tostring(best.pathOk)
+ " progression=" + str.tostring(best.progressionMatches) + "/" + str.tostring(best.progressionTests)
+ " traversal=" + str.tostring(best.traversalOk) + " realizedCompression=" + str.tostring(best.realizedCompressionOk)
+ " traversalRun=" + str.tostring(best.longestTraversalRun) + " traversalAvg=" + str.tostring(best.averageTraversal, "#.###")
+ " boundary=" + str.tostring(best.boundaryOk) + " anchorU/L=" + str.tostring(best.upperAnchorSpanRatio, "#.###") + "/" + str.tostring(best.lowerAnchorSpanRatio, "#.###")
+ " residualAtr=" + str.tostring(best.meanResidualAtr, "#.##") + " swingInside=" + str.tostring(best.swingInsideOk)
+ " distribution=" + str.tostring(best.distributionOk) + " flatSide=" + str.tostring(best.flatSideOk)
+ " apex=" + str.tostring(best.apexOk) + " apexDistanceRatio=" + str.tostring(best.apexDistanceRatio, "#.##")
+ " apexProgress=" + str.tostring(best.apexProgress, "#.##") + " lineQuality=" + str.tostring(best.lineQualityOk)
+ " slopeRatio=" + str.tostring(best.slopeRatio, "#.##") + " duration=" + str.tostring(best.durationOk))
// Lightweight candidate triangle for the current best-in-window, redrawn
// on every search regardless of whether it clears minScore.
f_clear_candidate()
if showCandidate and best.structureOk and best.score >= candidateThreshold and not best.valid
candidateEndX = math.min(bar_index, f_line_cap_x(best))
candidateUpperStart = f_price(best.xU1, best.yU1, best.xU2, best.yU2, best.windowStartX)
candidateUpperEnd = f_price(best.xU1, best.yU1, best.xU2, best.yU2, candidateEndX)
candidateLowerStart = f_price(best.xL1, best.yL1, best.xL2, best.yL2, best.windowStartX)
candidateLowerEnd = f_price(best.xL1, best.yL1, best.xL2, best.yL2, candidateEndX)
candidateAnchorOk = best.upperAnchorSpanRatio >= minAnchorSpanRatio and best.lowerAnchorSpanRatio >= minAnchorSpanRatio
candidateReason = not best.pathOk ? "Swing-Pfad" : not best.traversalOk ? "Traversal" : not best.realizedCompressionOk ? "Kompression" : not candidateAnchorOk ? "Anker-Span" : not best.swingInsideOk ? "Swing-Grenze" : not best.distributionOk ? "Kontaktverteilung" : not best.flatSideOk ? "Flat-Seite" : not best.boundaryOk ? "Boundary" : not best.apexOk ? "Apex" : not best.lineQualityOk ? "Linienqualität" : not best.durationOk ? "Dauer" : "Hard Gate"
candLn1 = line.new(best.windowStartX, candidateUpperStart, candidateEndX, candidateUpperEnd, color=candidateCol, style=line.style_dashed, width=2)
candLn2 = line.new(best.windowStartX, candidateLowerStart, candidateEndX, candidateLowerEnd, color=candidateCol, style=line.style_dashed, width=2)
array.push(candidateLines, candLn1)
array.push(candidateLines, candLn2)
candidateLabelY = math.min(math.max(close, math.min(candidateLowerEnd, candidateUpperEnd)), math.max(candidateLowerEnd, candidateUpperEnd))
candLabel = label.new(candidateEndX, candidateLabelY, "WATCH · " + best.ttype + " · " + str.tostring(best.score) + "\nBlocker: " + candidateReason, style=label.style_label_down, color=candidateCol, textcolor=color.white, size=size.small)
array.push(candidateLabels, candLabel)
// Every distinct, non-overlapping valid triangle gets its own tracked
// pattern. An overlapping candidate only replaces the existing one at
// that spot if it scores higher and isn't literally the same window.
if array.size(candidatesPerWindowEnd) > 0
for i = 0 to array.size(candidatesPerWindowEnd) - 1
c = array.get(candidatesPerWindowEnd, i)
if c.valid
// The newest swing needs pivotLen bars of opposite-direction
// movement to even confirm — and that's often the same move
// that already carries price through a boundary that, by
// the candidate's own end width, was already tight at
// windowEndX. Without this check, most detected triangles
// are stillborn: already broken out the instant they're
// found, so they'd be drawn and deleted one bar later
// without ever being visibly "live".
// Same frozen-at-cap evaluation as the tracking loop: a
// window-end deep in `Suchbereich letzte Swings` can be old
// enough that extrapolating all the way to today's bar_index
// already overshoots its own capped extension — evaluate at
// the same bounded x so a merely-old-but-still-plausible
// candidate isn't rejected as "stillborn" for a reason that
// has nothing to do with whether it actually broke out.
stillOpenEvalX = math.min(bar_index, f_line_cap_x(c))
upperAtNow = f_price(c.xU1, c.yU1, c.xU2, c.yU2, stillOpenEvalX)
lowerAtNow = f_price(c.xL1, c.yL1, c.xL2, c.yL2, stillOpenEvalX)
breakoutBuffer = breakoutBufferAtr * atr
stillOpen = close < upperAtNow + breakoutBuffer and close > lowerAtNow - breakoutBuffer
// `Suchbereich letzte Swings` can resurface a window-end that's
// already past its own age-expiry threshold by the time it's
// found (log-confirmed on Broadening Wedge Scanner Pro: fired,
// then expired on the very same bar with age=220 against an
// 81-bar window, 1.5×=121.5) — gate it here instead of firing
// it just to immediately expire it, which wastes a cycle and
// leaves the one tracking slot empty for a whole search event
// over a candidate that was never going to live anyway.
alreadyAged = bar_index - c.windowEndX > maxPatternAgeMult * math.max(c.windowEndX - c.windowStartX, 1)
if alreadyAged
f_log("PATTERN SKIPPED (already aged) type=" + c.ttype + " score=" + str.tostring(c.score) + " age=" + str.tostring(bar_index - c.windowEndX))
else if not stillOpen
f_log("PATTERN SKIPPED (stillborn) type=" + c.ttype + " score=" + str.tostring(c.score))
if stillOpen and not alreadyAged
// A resolved-and-kept triangle no longer sits in
// trackedPatterns, so without this check a later
// candidate at the same spot would draw right on top of
// it regardless of quality. Only let the new one through
// if it actually outscores the kept one there.
keptOverlapIdx = f_find_overlap_index(c, keptPatterns)
keptBlocks = keptOverlapIdx >= 0 and array.get(keptPatterns, keptOverlapIdx).p.score >= c.score
if keptBlocks
f_log("PATTERN SKIPPED (kept overlap scores higher) type=" + c.ttype + " score=" + str.tostring(c.score))
if not keptBlocks
if keptOverlapIdx >= 0
outscoredKept = array.get(keptPatterns, keptOverlapIdx)
f_delete_tracked(outscoredKept)
array.remove(keptPatterns, keptOverlapIdx)
overlapIdx = f_find_overlap_index(c, trackedPatterns)
if overlapIdx < 0
if array.size(trackedPatterns) < maxConcurrentPatterns
newTp = f_draw_pattern(c)
array.push(trackedPatterns, newTp)
newTriangleFound := true
f_log("PATTERN FIRED type=" + c.ttype + " score=" + str.tostring(c.score)
+ " start=" + str.tostring(c.windowStartX) + " end=" + str.tostring(c.windowEndX))
else
// Once every slot is full, a genuinely new, non-
// overlapping candidate must be able to evict the
// weakest tracked triangle if it outscores it —
// otherwise the first `maxConcurrentPatterns` triangles
// that happen not to break out for a while permanently
// occupy every slot and silently block every better
// candidate found afterward.
weakestIdx = f_find_weakest_index()
weakest = array.get(trackedPatterns, weakestIdx)
if c.score > weakest.p.score
if showOldPatterns
f_finalize_and_keep(weakest)
else
f_delete_tracked(weakest)
newTp = f_draw_pattern(c)
array.set(trackedPatterns, weakestIdx, newTp)
newTriangleFound := true
f_log("PATTERN EVICTED old_score=" + str.tostring(weakest.p.score) + " type=" + c.ttype + " score=" + str.tostring(c.score))
else
existing = array.get(trackedPatterns, overlapIdx)
if c.windowEndX != existing.p.windowEndX and c.score > existing.p.score
if showOldPatterns
f_finalize_and_keep(existing)
else
f_delete_tracked(existing)
newTp = f_draw_pattern(c)
array.set(trackedPatterns, overlapIdx, newTp)
newTriangleFound := true
f_log("PATTERN REPLACED type=" + c.ttype + " score=" + str.tostring(c.score))
// Cleans up any same-direction overlap chain the pairwise checks above
// can miss (A overlaps B, B overlaps C, but A and C don't directly
// overlap each other).
f_dedupe_overlaps()
// `keptPatterns` exists purely so a future overlapping candidate can be
// compared against a resolved shape's score instead of drawing over it —
// but with no cleanup, every resolved pattern piled up here forever
// across the entire chart history (default `Aufgelöste Patterns
// behalten` is on), growing `f_find_overlap_index(c, keptPatterns)`'s
// linear scan cost on every single search event, year after year. A kept
// entry old enough that even a LIVE tracked pattern of the same age
// would already have expired can never usefully be compared against
// again — no future search window reaches that far back — so it's
// trimmed on the same age basis instead of kept indefinitely.
if array.size(keptPatterns) > 0
for i = array.size(keptPatterns) - 1 to 0
kp = array.get(keptPatterns, i)
kpWindowBars = kp.p.windowEndX - kp.p.windowStartX
if bar_index - kp.p.windowEndX > maxPatternAgeMult * math.max(kpWindowBars, 1)
f_delete_tracked(kp)
array.remove(keptPatterns, i)
// ============================================================================
// Breakout tracking (every bar — price can break either boundary any time)
// ============================================================================
breakoutBull = false
breakoutBear = false
if array.size(trackedPatterns) > 0
for i = array.size(trackedPatterns) - 1 to 0
tp = array.get(trackedPatterns, i)
// Evaluated at min(bar_index, capX) instead of bar_index directly: once
// a pattern has run past its fixed extension budget (one more
// window-width beyond its own formation, see f_line_cap_x), its
// boundary level FREEZES at whatever it was at the cap instead of
// continuing to extrapolate forever. A pattern that hasn't broken
// that frozen level within a reasonable time is stale (handled by
// `aged` below), not still meaningfully "live".
capX = f_line_cap_x(tp.p)
evalX = math.min(bar_index, capX)
upperNow = f_price(tp.p.xU1, tp.p.yU1, tp.p.xU2, tp.p.yU2, evalX)
lowerNow = f_price(tp.p.xL1, tp.p.yL1, tp.p.xL2, tp.p.yL2, evalX)
// One-bar grace period only — not a "fresh cross" requirement. A
// fresh-cross check (close[1] on the inside, close now outside)
// sounds cleaner, but windowEndX is always in the past by
// construction (pivotLen swing-confirmation lag), so it can never
// gate anything; and if price is already outside the very first
// time a pattern is tracked, the "fresh cross" moment already
// happened and never recurs — the pattern then sits in
// trackedPatterns forever, permanently occupying one of the
// maxConcurrentPatterns slots. This just skips the check on the
// pattern's own creation bar (avoids labeling a breakout in the
// same instant the triangle is drawn) and falls back to a plain
// containment check every bar after, so a pattern that's already
// outside always resolves on the very next bar.
justCreated = bar_index <= tp.createdBar
breakoutBuffer = breakoutBufferAtr * atr
bullHit = not justCreated and close > upperNow + breakoutBuffer
bearHit = not justCreated and close < lowerNow - breakoutBuffer
// Classic TA treats a pattern that hasn't resolved within a
// reasonable multiple of its own formation length as stale, not
// live — same principle applied here. This is the sole cleanup
// mechanism now (an ATR-based drift check was tried and removed:
// log-confirmed on Broadening Wedge Scanner Pro it was killing
// legitimate, already-capped patterns on arrival — ATR measures
// bar-to-bar noise, not the honest cumulative price range a
// triangle's own real slope implies over dozens-to-hundreds of
// bars, so it was never a calibrated way to judge this).
windowBars = tp.p.windowEndX - tp.p.windowStartX
aged = bar_index - tp.p.windowEndX > maxPatternAgeMult * math.max(windowBars, 1)
if bullHit or bearHit
breakoutBull := breakoutBull or bullHit
breakoutBear := breakoutBear or bearHit
col = bullHit ? bullCol : bearCol
txt = bullHit ? "Breakout ↑" : "Breakdown ↓"
lb = label.new(bar_index, bullHit ? upperNow : lowerNow, txt,
style=bullHit ? label.style_label_up : label.style_label_down,
color=col, textcolor=color.white, size=size.small)
array.push(tp.objLabels, lb)
f_log("BREAKOUT bull=" + str.tostring(bullHit) + " type=" + tp.p.ttype)
if showOldPatterns
f_finalize_and_keep(tp)
else
f_delete_tracked(tp)
array.remove(trackedPatterns, i)
else if aged
f_log("PATTERN EXPIRED type=" + tp.p.ttype + " score=" + str.tostring(tp.p.score) + " age=" + str.tostring(bar_index - tp.p.windowEndX))
if showOldPatterns
f_finalize_and_keep(tp)
else
f_delete_tracked(tp)
array.remove(trackedPatterns, i)
else if array.size(tp.objLines) >= 2
// Still open: keep advancing the boundary lines' far end to stay
// just `lineAheadBars` ahead of the current bar, capped at capX so
// it stops growing once the fixed extension budget is used up
// instead of drawing all the way across the chart.
aheadX = math.min(bar_index + lineAheadBars, capX)
line.set_xy2(array.get(tp.objLines, 0), aheadX, f_price(tp.p.xU1, tp.p.yU1, tp.p.xU2, tp.p.yU2, aheadX))
line.set_xy2(array.get(tp.objLines, 1), aheadX, f_price(tp.p.xL1, tp.p.yL1, tp.p.xL2, tp.p.yL2, aheadX))
// ============================================================================
// Near-miss candidate marker (Watch signal, visual only)
// ============================================================================
plotshape(newSwing and showCandidate and best.score >= candidateThreshold and not best.valid and best.structureOk, title="Triangle Candidate", style=shape.circle, location=location.abovebar, size=size.tiny, color=candidateCol)
// ============================================================================
// Dashboard
// ============================================================================
if showDashboard and barstate.islast
statusActive = array.size(trackedPatterns) > 0
// Reflects the actually tracked pattern when one exists. Without one, a
// valid candidate is never "ready": a valid, open, non-aged candidate is
// tracked the moment it is found, so an untracked valid `best` is always
// stillborn, aged out or already resolved. Only a near-miss (structure
// OK, not valid — the same rule as the chart's candidate marker) is shown
// as WATCH; otherwise SCAN with no pattern details.
dashWatch = not statusActive and best.structureOk and best.score >= candidateThreshold and not best.valid
Pattern dashPattern = emptyPattern()
if statusActive
dashPattern := array.get(trackedPatterns, f_find_best_active_index()).p
else if dashWatch
dashPattern := best
dashStatus = statusActive ? "ACTIVE" : dashWatch ? "WATCH" : "SCAN"
table.cell(dash, 0, 0, "Triangle Scanner", text_color=tc, bgcolor=hd, text_size=size.small)
table.cell(dash, 1, 0, dashStatus, text_color=color.white, bgcolor=statusActive ? color.new(triangleCol, 0) : dashWatch ? candidateCol : color.new(color.gray, 80), text_size=size.small)
table.cell(dash, 0, 1, "Active Patterns", text_color=tc, text_size=size.small)
table.cell(dash, 1, 1, str.tostring(array.size(trackedPatterns)) + " / " + str.tostring(maxConcurrentPatterns), text_color=tc, text_size=size.small)
table.cell(dash, 0, 2, "Best Score", text_color=tc, text_size=size.small)
table.cell(dash, 1, 2, str.tostring(dashPattern.score) + " / " + str.tostring(minScore), text_color=tc, text_size=size.small)
table.cell(dash, 0, 3, "Type", text_color=tc, text_size=size.small)
table.cell(dash, 1, 3, dashPattern.structureOk ? dashPattern.ttype : "—", text_color=tc, text_size=size.small)
table.cell(dash, 0, 4, "Window", text_color=tc, text_size=size.small)
table.cell(dash, 1, 4, na(dashPattern.windowStartX) ? "—" : (str.tostring(dashPattern.windowEndX - dashPattern.windowStartX) + " Bars"), text_color=tc, text_size=size.small)
// ============================================================================
// Debug: score breakdown of the current best-in-depth candidate
// ============================================================================
if showDebug and barstate.islast
debugAnchorSpanOk = best.upperAnchorSpanRatio >= minAnchorSpanRatio and best.lowerAnchorSpanRatio >= minAnchorSpanRatio
debugEvalX = best.structureOk ? math.min(bar_index, f_line_cap_x(best)) : bar_index
debugUpperNow = best.structureOk ? f_price(best.xU1, best.yU1, best.xU2, best.yU2, debugEvalX) : na
debugLowerNow = best.structureOk ? f_price(best.xL1, best.yL1, best.xL2, best.yL2, debugEvalX) : na
debugBreakoutBuffer = breakoutBufferAtr * atr
debugStillOpen = best.structureOk and close < debugUpperNow + debugBreakoutBuffer and close > debugLowerNow - debugBreakoutBuffer
debugAlreadyAged = best.structureOk and bar_index - best.windowEndX > maxPatternAgeMult * math.max(best.windowEndX - best.windowStartX, 1)
debugTrackability = not best.valid ? (best.score < minScore ? "SCORE" : "HARD GATE") : debugAlreadyAged ? "ZU ALT" : not debugStillOpen ? "AUSGEBROCHEN" : "TRACKABLE"
table.cell(dbgTable, 0, 0, "Score (Σ/80 → 100)", text_color=tc, bgcolor=hd, text_size=size.small)
table.cell(dbgTable, 1, 0, str.tostring(best.score) + " / " + str.tostring(minScore), text_color=tc, bgcolor=hd, text_size=size.small)
table.cell(dbgTable, 0, 1, "Swing-Pfad (Gate)", text_color=tc, text_size=size.small)
table.cell(dbgTable, 1, 1, str.tostring(best.pathScore) + (best.pathOk ? " · OK" : " · GATE"), text_color=tc, text_size=size.small)
table.cell(dbgTable, 0, 2, "Pfad-Fortschritt", text_color=tc, text_size=size.small)
table.cell(dbgTable, 1, 2, str.tostring(best.progressionMatches) + "/" + str.tostring(best.progressionTests), text_color=tc, text_size=size.small)
table.cell(dbgTable, 0, 3, "Traversal (20)", text_color=tc, text_size=size.small)
table.cell(dbgTable, 1, 3, str.tostring(best.traversalScore) + (best.traversalOk ? " · OK" : " · GATE"), text_color=tc, text_size=size.small)
table.cell(dbgTable, 0, 4, "Traversal Run / Ø", text_color=tc, text_size=size.small)
table.cell(dbgTable, 1, 4, str.tostring(best.longestTraversalRun) + "/3 · " + str.tostring(best.averageTraversal, "#.##"), text_color=tc, text_size=size.small)
table.cell(dbgTable, 0, 5, "Real. Kompression (15)", text_color=tc, text_size=size.small)
table.cell(dbgTable, 1, 5, str.tostring(best.compressionScore) + (best.realizedCompressionOk ? " · OK" : " · GATE"), text_color=tc, text_size=size.small)
table.cell(dbgTable, 0, 6, "Boundary-Nähe (15)", text_color=tc, text_size=size.small)
table.cell(dbgTable, 1, 6, str.tostring(best.touchScore), text_color=tc, text_size=size.small)
table.cell(dbgTable, 0, 7, "Residual / Swings", text_color=tc, text_size=size.small)
table.cell(dbgTable, 1, 7, str.tostring(best.meanResidualAtr, "#.##") + " ATR / " + (best.swingInsideOk ? "OK" : "GATE"), text_color=tc, text_size=size.small)
table.cell(dbgTable, 0, 8, "Kontaktzonen / Flat", text_color=tc, text_size=size.small)
table.cell(dbgTable, 1, 8, str.tostring(best.upperZoneCount) + "/" + str.tostring(best.lowerZoneCount) + " / " + (best.distributionOk ? "D OK" : "D GATE") + " / " + (best.flatSideOk ? "F OK" : "F GATE"), text_color=tc, text_size=size.small)
table.cell(dbgTable, 0, 9, "Linienqualität (10)", text_color=tc, text_size=size.small)
table.cell(dbgTable, 1, 9, str.tostring(best.lineScore) + " · Ratio " + (na(best.slopeRatio) ? "n/a" : str.tostring(best.slopeRatio, "#.##")) + (best.lineQualityOk ? " · OK" : " · GATE"), text_color=tc, text_size=size.small)
table.cell(dbgTable, 0, 10, "Anker U/L (10)", text_color=tc, text_size=size.small)
table.cell(dbgTable, 1, 10, str.tostring(best.boundaryScore) + " · " + str.tostring(best.upperAnchorSpanRatio, "#.###") + "/" + str.tostring(best.lowerAnchorSpanRatio, "#.###") + (debugAnchorSpanOk ? " · OK" : " · GATE"), text_color=tc, text_size=size.small)
table.cell(dbgTable, 0, 11, "Apex Dist. / Reife", text_color=tc, text_size=size.small)
table.cell(dbgTable, 1, 11, str.tostring(best.apexDistanceRatio, "#.##") + "× / " + str.tostring(best.apexProgress, "#.##") + (best.apexOk ? " · OK" : " · GATE"), text_color=tc, text_size=size.small)
table.cell(dbgTable, 0, 12, "Dauer (5)", text_color=tc, text_size=size.small)
table.cell(dbgTable, 1, 12, str.tostring(best.durationScore) + (best.durationOk ? " · OK" : " · GATE"), text_color=tc, text_size=size.small)
table.cell(dbgTable, 0, 13, "Aktualität (5)", text_color=tc, text_size=size.small)
table.cell(dbgTable, 1, 13, str.tostring(best.relevanceScore), text_color=tc, text_size=size.small)
table.cell(dbgTable, 0, 14, "Containment (soft)", text_color=tc, text_size=size.small)
table.cell(dbgTable, 1, 14, str.tostring(best.containmentRatio * 100.0, "#.0") + " %" + (best.barsInsideOk ? " · OK" : ""), text_color=tc, text_size=size.small)
table.cell(dbgTable, 0, 15, "Pattern-Gültigkeit", text_color=tc, text_size=size.small)
table.cell(dbgTable, 1, 15, best.valid ? "VALID" : "INVALID", text_color=tc, text_size=size.small)
table.cell(dbgTable, 0, 16, "Trackability", text_color=tc, text_size=size.small)
table.cell(dbgTable, 1, 16, debugTrackability, text_color=tc, text_size=size.small)
// ============================================================================
// Alerts
// ============================================================================
alertOk = not alertsOnBarClose or barstate.isconfirmed
alertcondition(alertOk and newTriangleFound, "Neues Dreieck erkannt", "TCS · TRIANGLE FOUND · {{ticker}} {{interval}}")
alertcondition(alertOk and breakoutBull, "Bullischer Breakout", "TCS · BULL BREAKOUT · {{ticker}} {{interval}}")
alertcondition(alertOk and breakoutBear, "Bärischer Breakdown", "TCS · BEAR BREAKDOWN · {{ticker}} {{interval}}")
Overview
Triangle Compression Scanner Pro [WavesUnchained] fits upper and lower boundary trend lines across swing highs and lows, rating each pattern with a composite quality score based on traversal, compression, boundary fit, and apex proximity. When price breaches a boundary by an ATR buffer, it flags a breakout. The indicator plots boundaries, status labels, and an optional metrics dashboard directly on the main chart.
Sliding-window swing search for compressing triangle patterns
Multi-factor 0 to 100 quality scoring algorithm
Directional breakout tracking with ATR buffer
Near-miss candidate pattern preview and debug dashboard
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
24
searchDepth
Swings pro Dreieck-Fenster
int
8
triangleWindow
Max. gleichzeitige Patterns
int
1
maxConcurrentPatterns
Max. Pattern-Alter (× Fensterbreite)
float
1.5
maxPatternAgeMult
Mindestqualität
int
60
minScore
ATR-Länge
int
14
atrLen
Flat-Toleranz (ATR über Fensterbreite)
float
0.5
flatSlopeAtrTol
Touch-Toleranz ATR
float
0.5
touchTolAtr
Ziel proj. Endbreite / Startbreite
float
0.75
projectedCompressionTarget
Mindest-Startbreite ATR
float
1.0
minWidthAtr
Show all 31 settings
Setting
Type
Default
Variable
Min. Traversal pro Leg
float
0.55
minTraversalRatio
Max. realisierte Leg-Kompression
float
0.85
maxRealizedCompressionRatio
Min. Anker-Span / Pattern-Dauer
float
0.45
minAnchorSpanRatio
Max. mittleres Boundary-Residuum ATR
float
1.0
maxMeanResidualAtr
Max. Apex-Distanz (× Pattern-Dauer)
float
1.25
maxApexDistanceMult
Breakout-Puffer ATR
float
0.15
breakoutBufferAtr
Aufgelöste Patterns behalten
bool
true
showOldPatterns
Kandidaten anzeigen
bool
true
showCandidate
Score-Label anzeigen
bool
true
showLabels
Linien-Überstand (Bars)
int
10
lineAheadBars
Dreieck (offen)
color
color.blue
triangleCol
Bullischer Breakout
color
color.lime
bullCol
Bärischer Breakout
color
color.red
bearCol
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.
Sideway Zones Live is an overlay indicator that estimates inner and outer sideways bands from recent highs and lows with a rational quadratic kernel. It tracks swing pivots over a loopback period and counts bars that remain between the outer high and low. Afte
This indicator identifies High Of Low Price (HOLP) and Low Of High Price (LOHP) candlestick setups based on price extremes over a specified lookback period. A HOLP signal triggers when the current bar closes above the high of a recent session low, whereas a LO
This indicator plots dynamic support and resistance levels on the price chart. It detects pivot highs and lows using the pivot length and tracks touches to build strength-based levels. Support and resistance zones appear as colored boxes while lines mark key l
This indicator calculates and plots Donchian Channels directly on the price chart. It determines the highest high and lowest low over a user-selected lookback period using an optimized monotonic deque algorithm, as well as a middle basis line computed as their
This indicator tracks bar lows during consecutive rising highs using Pine Script arrays. When at least four lows accumulate or the price breaches the minimum low in the stack, the script sorts the recorded lows in ascending order and flushes them. It plots up
This indicator tracks smart money market structure directly on the price chart. It calculates dual-period swing highs and swing lows to detect Changes of Character (CHoCH), Breaks of Structure (BOS), inducements, and liquidity sweeps. When structural levels ar