Breakout Pattern Setup [WillyAlgoTrader]

WillyAlgoTrader · study · 916 行 · 点赞 7,088 · TradingView 原页

本页源码来自 TradingView 公开发布的开源脚本,版权归原作者所有, 请遵循其原始许可(Pine 脚本常见 CC BY-NC-SA / MPL-2.0 / MIT)。 本项目仅用于研究检索与许可范围内的移植。

Pine Script

//@version=6
// ══════════════════════════════════════════════════════════
// BREAKOUT PATTERN SETUP [WillyAlgoTrader]
// ══════════════════════════════════════════════════════════
// Author:  Willy | WillyAlgoTrader
// Version: 1.7.0
 
indicator(
    title              = "Breakout Pattern Setup [WillyAlgoTrader]",
    shorttitle         = "BPS",
    overlay            = true,
    max_lines_count    = 500,
    max_labels_count   = 500,
    max_bars_back      = 5000)
 
// ══════════════════════════════════════
// CONSTANTS
// ══════════════════════════════════════
GRP_MAIN   = "⚙️ Main Settings"
GRP_FILTER = "🔍 Filters"
GRP_VISUAL = "🎨 Visual"
GRP_RISK   = "🛡️ Risk Management"
GRP_LINES  = "📐 Channel Lines"
GRP_DASH   = "📊 Dashboard"
GRP_ALERT  = "🔔 Alerts"
GRP_COLORS = "🎨 Colors"
GRP_ADV    = "🔧 Advanced"
 
INDICATOR_VERSION = "v1.7.0"
int MAX_PIVOTS         = 60
int RESCAN_INTERVAL    = 10
int MAX_PIVOT_SCAN     = 15
int WARMUP_MINIMUM     = 50
float LINE_EPSILON     = 1e-10
float MAX_WIDTH_MULT   = 10.0
float VOL_CONTR_THRESH = 0.85
float BG_TRANSPARENCY  = 90.0
int MAX_HIST_LINES     = 400
int MAX_HIST_FILLS     = 200
int MAX_HIST_LABELS    = 200
 
// ══════════════════════════════════════
// INPUTS
// ══════════════════════════════════════
pivotLenInput = input.int(5, "Pivot Detection Length", minval = 2, maxval = 30, group = GRP_MAIN, tooltip = "Bars left/right for swing pivot detection.\n• Recommended: 3–10")
minTouchesInput = input.int(2, "Min Touches per Boundary", minval = 2, maxval = 6, group = GRP_MAIN, tooltip = "Minimum pivot touches per trendline.\n• Recommended: 2–3")
maxChannelBarsInput = input.int(120, "Max Channel Width (bars)", minval = 20, maxval = 500, step = 10, group = GRP_MAIN, tooltip = "Maximum lookback.\n• Recommended: 60–150")
convergenceMinInput = input.float(0.02, "Min Convergence Rate", minval = 0.0, maxval = 0.5, step = 0.01, group = GRP_MAIN, tooltip = "Min narrowing rate (0–1).\n• Recommended: 0.02–0.15")
touchToleranceInput = input.float(0.15, "Touch Tolerance (ATR)", minval = 0.01, maxval = 0.5, step = 0.01, group = GRP_ADV, tooltip = "Pivot distance to count as touch.\n• Recommended: 0.10–0.25")
deviationMaxInput = input.float(0.3, "Max Deviation (ATR)", minval = 0.05, maxval = 1.0, step = 0.05, group = GRP_ADV, tooltip = "Max pivot overshoot.\n• Recommended: 0.2–0.5")
minChannelWidthAtrInput = input.float(0.5, "Min Channel Width (ATR)", minval = 0.1, maxval = 3.0, step = 0.1, group = GRP_ADV, tooltip = "Min width filter.\n• Recommended: 0.3–1.0")
 
volConfirmInput = input.float(1.2, "Volume Spike Mult", minval = 0.5, maxval = 3.0, step = 0.1, group = GRP_FILTER, tooltip = "Volume vs SMA(20).\n• Set 0.5 to disable on forex")
volContractionInput = input.bool(true, "Volume Contraction Filter", group = GRP_FILTER, tooltip = "Require volume decline inside channel")
momentumFilterInput = input.bool(true, "Momentum Confirmation", group = GRP_FILTER, tooltip = "RSI > 50 for bull, < 50 for bear")
 
showPatternsInput    = input.bool(true, "Show Channel Patterns", group = GRP_VISUAL)
showSignalsInput     = input.bool(true, "Show Breakout Signals", group = GRP_VISUAL)
showBgInput          = input.bool(false, "Show Pattern Background", group = GRP_VISUAL, tooltip = "Subtle bgcolor while price is inside channel")
historyModeInput     = input.string("Last Only", "Pattern History", options = ["All", "Last Only"], group = GRP_VISUAL, tooltip = "All: full history | Last Only: clean chart")
labelSizeInput       = input.string("Small", "Label Size", options = ["Tiny", "Small", "Normal"], group = GRP_VISUAL, tooltip = "Size of ▲ ▼ ✗ labels")
showWatermarkInput   = input.bool(true, "Show Watermark", group = GRP_VISUAL)
 
showFillInput        = input.bool(true, "Show Channel Fill", group = GRP_LINES, tooltip = "Show/hide the shaded area between channel boundaries")
lineColorInput       = input.color(#FFEB3B, "Line Color", group = GRP_LINES, tooltip = "Color of channel boundary lines (before breakout)")
lineWidthInput       = input.int(2, "Line Width", minval = 1, maxval = 4, group = GRP_LINES, tooltip = "Thickness of channel boundary lines.\n• 1 = thin\n• 2 = default\n• 3–4 = bold")
 
showLevelsInput = input.bool(true, "Show Trade Levels", group = GRP_RISK, tooltip = "Display Entry / SL / TP1 / TP2 / TP3 lines and labels")
slPaddingInput = input.float(0, "SL Padding (ATR)", minval = 0.0, maxval = 1.0, step = 0.05, group = GRP_RISK, tooltip = "Extra buffer beyond opposite boundary for SL.\n• 0 = exactly at boundary\n• 0.2 = boundary + 0.2×ATR\n• Recommended: 0.1–0.3")
labelOffsetInput = input.int(20, "Label Offset (bars)", minval = 5, maxval = 50, group = GRP_RISK, tooltip = "How far right of last candle to place level labels.\n• Recommended: 15–25")
 
showDashInput = input.bool(true, "Show Dashboard", group = GRP_DASH)
dashPosStr    = input.string("Top Right", "Position", options = ["Top Left", "Top Right", "Bottom Left", "Bottom Right"], group = GRP_DASH)
dashSizeInput = input.string("Small", "Font Size", options = ["Tiny", "Small", "Normal"], group = GRP_DASH, tooltip = "Dashboard text size.\n• Tiny: compact\n• Small: default\n• Normal: larger, easier to read")
 
themeInput = input.string("Auto", "Theme", options = ["Auto", "Dark", "Light"], group = GRP_VISUAL)
 
bullColorInput    = input.color(#00E676, "Bull", inline = "colors", group = GRP_COLORS)
bearColorInput    = input.color(#FF5252, "Bear", inline = "colors", group = GRP_COLORS)
neutralColorInput = input.color(#FFEB3B, "Neutral", inline = "colors", group = GRP_COLORS)
 
webhookInput = input.bool(false, "Webhook JSON Format", group = GRP_ALERT)
 
// ══════════════════════════════════════
// THEME DETECTION & COLORS
// ══════════════════════════════════════
isDark = switch themeInput
    "Dark"  => true
    "Light" => false
    => color.r(chart.bg_color) < 128
 
TEXT_COLOR    = isDark ? #E0E0E0 : #1A1A1A
TEXT_MUTED    = isDark ? color.new(#9E9E9E, 0) : color.new(#757575, 0)
TEXT_BRIGHT   = isDark ? #FFFFFF : #000000
TABLE_BG      = isDark ? color.new(#131722, 5) : color.new(#FFFFFF, 5)
TABLE_BORDER  = isDark ? color.new(#2A2E39, 0) : color.new(#D0D0D0, 0)
TABLE_ROW_ALT = isDark ? color.new(#1C2030, 0) : color.new(#F0F4F8, 0)
HEADER_BG     = color.new(#2962FF, 0)
LINE_SUBTLE   = isDark ? color.new(#555555, 0) : color.new(#AAAAAA, 0)
WM_COLOR      = isDark ? color.new(#FFFFFF, 80) : color.new(#000000, 80)
INVALID_COLOR = isDark ? color.new(#FF9800, 0) : color.new(#E65100, 0)
 
DASH_GREEN  = isDark ? color.new(#00E676, 0) : color.new(#1B8A2A, 0)
DASH_RED    = isDark ? color.new(#FF5252, 0) : color.new(#CC1A1A, 0)
DASH_CYAN   = isDark ? color.new(#40E0D0, 0) : color.new(#0E8C7F, 0)
 
BULL_BG = color.new(bullColorInput, 88)
BEAR_BG = color.new(bearColorInput, 88)
CHANNEL_LINE_COLOR = color.new(lineColorInput, 50)
 
SL_COLOR     = color.new(#FF1744, 0)
TP_COLOR     = color.new(#00E676, 0)
ENTRY_COLOR  = color.new(#2979FF, 0)
TP_HIT_COLOR = color.new(#40E0D0, 0)
CHANNEL_FILL_COLOR = showFillInput ? color.new(lineColorInput, 90) : color.new(lineColorInput, 100)
 
// ══════════════════════════════════════
// RESOLVERS
// ══════════════════════════════════════
dashSize = switch dashSizeInput
    "Tiny"   => size.tiny
    "Normal" => size.normal
    => size.small
 
lblSize = switch labelSizeInput
    "Tiny"   => size.tiny
    "Normal" => size.normal
    => size.small
 
dashPos = switch dashPosStr
    "Top Left"     => position.top_left
    "Top Right"    => position.top_right
    "Bottom Left"  => position.bottom_left
    "Bottom Right" => position.bottom_right
    => position.top_right
 
// ══════════════════════════════════════
// PURE FUNCTIONS (no global dependencies)
// ══════════════════════════════════════
safeDiv(float num, float den, float fallback = 0.0) =>
    den != 0 and not na(num) and not na(den) ? num / den : fallback
 
lineAt(float x1, float y1, float x2, float y2, float x) =>
    float dx = x2 - x1
    math.abs(dx) > LINE_EPSILON ? y1 + (y2 - y1) * (x - x1) / dx : y1
 
// ══════════════════════════════════════
// CORE CALCULATIONS
// ══════════════════════════════════════
float atr20    = nz(ta.atr(20), syminfo.mintick * 10)
float rsiVal   = nz(ta.rsi(close, 14), 50.0)
float volSma   = nz(ta.sma(volume, 20), 0.0)
bool  hasVolume = nz(volume, 0) > 0
float cumVol   = ta.cum(nz(volume, 0))
 
int WARMUP_BARS = math.max(pivotLenInput * 2 + maxChannelBarsInput, WARMUP_MINIMUM)
bool isWarmedUp = bar_index >= WARMUP_BARS
bool showLastOnly = historyModeInput == "Last Only"
 
// ══════════════════════════════════════
// PIVOT DETECTION
// ══════════════════════════════════════
float pivHigh = ta.pivothigh(high, pivotLenInput, pivotLenInput)
float pivLow  = ta.pivotlow(low, pivotLenInput, pivotLenInput)
 
var array<float> hiPrices = array.new_float()
var array<int>   hiBars   = array.new_int()
var array<float> loPrices = array.new_float()
var array<int>   loBars   = array.new_int()
 
if not na(pivHigh)
    hiPrices.unshift(pivHigh)
    hiBars.unshift(bar_index - pivotLenInput)
if not na(pivLow)
    loPrices.unshift(pivLow)
    loBars.unshift(bar_index - pivotLenInput)
 
while hiPrices.size() > MAX_PIVOTS
    hiPrices.pop()
    hiBars.pop()
while loPrices.size() > MAX_PIVOTS
    loPrices.pop()
    loBars.pop()
 
// ══════════════════════════════════════
// CHANNEL STATE
// ══════════════════════════════════════
var bool     channelActive  = false
var float    hiX1 = 0.0
var float    hiY1 = 0.0
var float    hiX2 = 0.0
var float    hiY2 = 0.0
var float    loX1 = 0.0
var float    loY1 = 0.0
var float    loX2 = 0.0
var float    loY2 = 0.0
var int      chHiTouches   = 0
var int      chLoTouches   = 0
var float    chConvergence = 0.0
var float    chMaxWidth    = 0.0
var int      chDetectBar   = 0
var line     chHiLine      = na
var line     chLoLine      = na
var linefill chFill        = na
 
var int    breakoutDir     = 0
var int    breakoutBar     = 0
var float  targetPrice     = 0.0
var string breakStrength   = "—"
var bool   breakInvalid    = false
var float  volContraction  = 0.0
var float  entryPrice      = 0.0
var float  slPrice         = 0.0
var float  slPriceOrig     = 0.0
var float  tp1Price        = 0.0
var float  tp2Price        = 0.0
var float  tp3Price        = 0.0
var bool   tp1Hit = false
var bool   tp2Hit = false
var bool   tp3Hit = false
var bool   slHit  = false
var bool   tradeOpen = false
 
var int    totalPatterns   = 0
var int    bullBreaks      = 0
var int    bearBreaks      = 0
var int    invalidCount    = 0
var int    winCount        = 0
var int    lossCount       = 0
var int    lastSignalBar   = 0
var string lastSignalType  = "—"
var float  dirScore        = 50.0
 
var array<line>     histLines  = array.new<line>()
var array<linefill> histFills  = array.new<linefill>()
var array<label>    histLabels = array.new<label>()
 
// Trade level drawing objects
var line lineEntry  = na
var line lineSL     = na
var line lineTP1    = na
var line lineTP2    = na
var line lineTP3    = na
var label lbEntry   = na
var label lbSL      = na
var label lbTP1     = na
var label lbTP2     = na
var label lbTP3     = na
var bool hasTradeDrawings = false
 
// ══════════════════════════════════════
// FUNCTIONS (depend on global vars declared above)
// ══════════════════════════════════════
// Unified alert formatter — takes all needed values as parameters
formatAlertMsg(string action, string emoji, string strLbl, float _entry, float _sl, float _tp1, float _tp2, float _tp3) =>
    string tickerStr = syminfo.tickerid
    string tfStr     = timeframe.period
    string priceStr  = str.tostring(close, format.mintick)
    string slAlStr   = str.tostring(_sl, format.mintick)
    string t1Str     = str.tostring(_tp1, format.mintick)
    string t2Str     = str.tostring(_tp2, format.mintick)
    string t3Str     = str.tostring(_tp3, format.mintick)
    string jsonMsg   = '{"action":"' + action + '","ticker":"' + tickerStr + '","price":' + priceStr + ',"sl":' + slAlStr + ',"tp1":' + t1Str + ',"tp2":' + t2Str + ',"tp3":' + t3Str + ',"strength":"' + strLbl + '","tf":"' + tfStr + '"}'
    string textMsg   = emoji + " " + str.upper(action) + " [" + strLbl + "] | " + tickerStr + " | " + tfStr + " | Entry: " + priceStr + " | SL: " + slAlStr + " | TP1: " + t1Str + " | TP2: " + t2Str + " | TP3: " + t3Str
    webhookInput ? jsonMsg : textMsg
 
formatEventMsg(string eventName, string emoji) =>
    string p = str.tostring(close, format.mintick)
    webhookInput ? '{"event":"' + eventName + '","ticker":"' + syminfo.tickerid + '","price":' + p + '}' : emoji + " " + str.upper(eventName) + " | " + syminfo.tickerid + " | " + p
 
// Helper: clean up trade drawing objects (full delete — lines + labels)
deleteTradeDrawings() =>
    line.delete(lineEntry)
    line.delete(lineSL)
    line.delete(lineTP1)
    line.delete(lineTP2)
    line.delete(lineTP3)
    label.delete(lbEntry)
    label.delete(lbSL)
    label.delete(lbTP1)
    label.delete(lbTP2)
    label.delete(lbTP3)
 
deleteTradeLabels() =>
    label.delete(lbEntry)
    label.delete(lbSL)
    label.delete(lbTP1)
    label.delete(lbTP2)
    label.delete(lbTP3)
 
// ══════════════════════════════════════
// BOUNDARY-FIT CHANNEL DETECTION
// ══════════════════════════════════════
bool newPivot = not na(pivHigh) or not na(pivLow)
bool rescanDue = bar_index % RESCAN_INTERVAL == 0
bool shouldScan = (newPivot or rescanDue) and not channelActive and not tradeOpen and isWarmedUp and hiPrices.size() >= minTouchesInput and loPrices.size() >= minTouchesInput
 
bool channelJustDetected = false
 
if shouldScan
    var array<float> hX = array.new_float()
    var array<float> hY = array.new_float()
    var array<float> lX = array.new_float()
    var array<float> lY = array.new_float()
    hX.clear()
    hY.clear()
    lX.clear()
    lY.clear()
 
    for i = 0 to math.min(hiPrices.size() - 1, MAX_PIVOT_SCAN)
        if bar_index - hiBars.get(i) <= maxChannelBarsInput
            hX.push(float(hiBars.get(i)))
            hY.push(hiPrices.get(i))
    for i = 0 to math.min(loPrices.size() - 1, MAX_PIVOT_SCAN)
        if bar_index - loBars.get(i) <= maxChannelBarsInput
            lX.push(float(loBars.get(i)))
            lY.push(loPrices.get(i))
 
    int hCnt = hX.size()
    int lCnt = lX.size()
    float devTol   = atr20 * deviationMaxInput
    float touchTol = atr20 * touchToleranceInput
 
    float bestHiX1 = 0.0
    float bestHiY1 = 0.0
    float bestHiX2 = 0.0
    float bestHiY2 = 0.0
    int   bestHiTouches = 0
    bool  foundHi = false
 
    if hCnt >= minTouchesInput
        for a = 0 to hCnt - 2
            for b = a + 1 to hCnt - 1
                float ax = hX.get(a)
                float ay = hY.get(a)
                float bx = hX.get(b)
                float bPy = hY.get(b)
                if math.abs(bx - ax) >= 1.0
                    bool allBelow = true
                    int  touches = 0
                    for k = 0 to hCnt - 1
                        float projY = lineAt(ax, ay, bx, bPy, hX.get(k))
                        float diff = hY.get(k) - projY
                        if diff > devTol
                            allBelow := false
                            break
                        if math.abs(diff) <= touchTol
                            touches += 1
                    if allBelow and touches >= minTouchesInput and touches > bestHiTouches
                        bestHiTouches := touches
                        bestHiX1 := ax
                        bestHiY1 := ay
                        bestHiX2 := bx
                        bestHiY2 := bPy
                        foundHi  := true
 
    float bestLoX1 = 0.0
    float bestLoY1 = 0.0
    float bestLoX2 = 0.0
    float bestLoY2 = 0.0
    int   bestLoTouches = 0
    bool  foundLo = false
 
    if lCnt >= minTouchesInput
        for a = 0 to lCnt - 2
            for b = a + 1 to lCnt - 1
                float ax = lX.get(a)
                float ay = lY.get(a)
                float bx = lX.get(b)
                float bPy = lY.get(b)
                if math.abs(bx - ax) >= 1.0
                    bool allAbove = true
                    int  touches = 0
                    for k = 0 to lCnt - 1
                        float projY = lineAt(ax, ay, bx, bPy, lX.get(k))
                        float diff = lY.get(k) - projY
                        if diff < -devTol
                            allAbove := false
                            break
                        if math.abs(diff) <= touchTol
                            touches += 1
                    if allAbove and touches >= minTouchesInput and touches > bestLoTouches
                        bestLoTouches := touches
                        bestLoX1 := ax
                        bestLoY1 := ay
                        bestLoX2 := bx
                        bestLoY2 := bPy
                        foundLo  := true
 
    if foundHi and foundLo
        float hiMinX = math.min(bestHiX1, bestHiX2)
        float hiMaxX = math.max(bestHiX1, bestHiX2)
        float loMinX = math.min(bestLoX1, bestLoX2)
        float loMaxX = math.max(bestLoX1, bestLoX2)
        float overlapStart = math.max(hiMinX, loMinX)
        float overlapEnd   = math.min(hiMaxX, loMaxX)
        float maxH = 0.0
        if overlapEnd > overlapStart
            int samples = math.max(int((overlapEnd - overlapStart) / 5.0), 2)
            for s = 0 to samples
                float sx = overlapStart + (overlapEnd - overlapStart) * float(s) / float(samples)
                float hu = lineAt(bestHiX1, bestHiY1, bestHiX2, bestHiY2, sx)
                float lo = lineAt(bestLoX1, bestLoY1, bestLoX2, bestLoY2, sx)
                if hu - lo > maxH
                    maxH := hu - lo
        else
            float midX = (hiMinX + hiMaxX + loMinX + loMaxX) / 4.0
            maxH := math.abs(lineAt(bestHiX1, bestHiY1, bestHiX2, bestHiY2, midX) - lineAt(bestLoX1, bestLoY1, bestLoX2, bestLoY2, midX))
 
        float startX     = math.min(math.min(bestHiX1, bestHiX2), math.min(bestLoX1, bestLoX2))
        float endX       = float(bar_index)
        float upperNow   = lineAt(bestHiX1, bestHiY1, bestHiX2, bestHiY2, endX)
        float lowerNow   = lineAt(bestLoX1, bestLoY1, bestLoX2, bestLoY2, endX)
        float widthNow   = upperNow - lowerNow
        float upperStart = lineAt(bestHiX1, bestHiY1, bestHiX2, bestHiY2, startX)
        float lowerStart = lineAt(bestLoX1, bestLoY1, bestLoX2, bestLoY2, startX)
        float widthStart = upperStart - lowerStart
        float convRate   = widthStart > 0 ? 1.0 - safeDiv(widthNow, widthStart, 1.0) : 0.0
        bool  notInv     = widthNow > 0
        bool  isConv     = convRate >= convergenceMinInput
        bool  widthOk    = widthNow >= atr20 * minChannelWidthAtrInput and widthNow < atr20 * MAX_WIDTH_MULT
        bool  isInside   = notInv and close[1] <= upperNow and close[1] >= lowerNow
 
        int patStartIdx = int(startX)
        int patBars     = math.max(bar_index - patStartIdx, 1)
 
        // [FIX 1.5] Guard against patBars > bar_index
        int safPatBars  = math.min(patBars, bar_index)
        int preBars     = math.min(safPatBars, patStartIdx)
        float volInPat  = hasVolume and safPatBars > 0 ? safeDiv(cumVol - nz(cumVol[safPatBars], 0.0), float(safPatBars), volSma) : volSma
        float volPrePat = hasVolume and preBars > 0 ? safeDiv(nz(cumVol[safPatBars], 0.0) - nz(cumVol[safPatBars + preBars], 0.0), float(preBars), volSma) : volSma
        float volContRatio = safeDiv(volInPat, volPrePat, 1.0)
        bool  volContOk = volContractionInput ? (not hasVolume or volContRatio < VOL_CONTR_THRESH) : true
 
        if notInv and isConv and widthOk and isInside and volContOk
            // [FIX 1.6.2] In "Last Only" mode, clean up BOTH old channel AND old trade drawings
            if showLastOnly
                for ln in histLines
                    line.delete(ln)
                histLines.clear()
                for fl in histFills
                    linefill.delete(fl)
                histFills.clear()
                for lb in histLabels
                    label.delete(lb)
                histLabels.clear()
                hasTradeDrawings := false
 
            channelActive  := true
            hiX1 := bestHiX1
            hiY1 := bestHiY1
            hiX2 := bestHiX2
            hiY2 := bestHiY2
            loX1 := bestLoX1
            loY1 := bestLoY1
            loX2 := bestLoX2
            loY2 := bestLoY2
            chHiTouches   := bestHiTouches
            chLoTouches   := bestLoTouches
            chConvergence := convRate
            chMaxWidth    := maxH
            chDetectBar   := bar_index
            breakoutDir   := 0
            breakInvalid  := false
            breakStrength := "—"
            volContraction := volContRatio
            channelJustDetected := true
            totalPatterns += 1
 
// ── Trim hist arrays to prevent unbounded growth ──
while histLines.size() > MAX_HIST_LINES
    line.delete(histLines.shift())
while histFills.size() > MAX_HIST_FILLS
    linefill.delete(histFills.shift())
while histLabels.size() > MAX_HIST_LABELS
    label.delete(histLabels.shift())
 
// ── Draw / extend active channel ──
if channelActive and showPatternsInput
    float hiProjNow = lineAt(hiX1, hiY1, hiX2, hiY2, float(bar_index))
    float loProjNow = lineAt(loX1, loY1, loX2, loY2, float(bar_index))
 
    if channelJustDetected
        int drawStart  = int(math.min(math.min(hiX1, hiX2), math.min(loX1, loX2)))
        float hiStartY = lineAt(hiX1, hiY1, hiX2, hiY2, float(drawStart))
        float loStartY = lineAt(loX1, loY1, loX2, loY2, float(drawStart))
        if not na(chHiLine)
            line.delete(chHiLine)
        if not na(chLoLine)
            line.delete(chLoLine)
        if not na(chFill)
            linefill.delete(chFill)
        chHiLine := line.new(drawStart, hiStartY, bar_index, hiProjNow, color = CHANNEL_LINE_COLOR, width = lineWidthInput)
        chLoLine := line.new(drawStart, loStartY, bar_index, loProjNow, color = CHANNEL_LINE_COLOR, width = lineWidthInput)
        chFill   := linefill.new(chHiLine, chLoLine, CHANNEL_FILL_COLOR)
        histLines.push(chHiLine)
        histLines.push(chLoLine)
        histFills.push(chFill)
    else
        if not na(chHiLine)
            line.set_x2(chHiLine, bar_index)
            line.set_y2(chHiLine, hiProjNow)
        if not na(chLoLine)
            line.set_x2(chLoLine, bar_index)
            line.set_y2(chLoLine, loProjNow)
 
// ══════════════════════════════════════
// BREAKOUT DETECTION + STRENGTH
// ══════════════════════════════════════
bool rawBullBreak = false
bool rawBearBreak = false
float strengthScore = 0.0
float breakBoundary = 0.0
float oppBoundary   = 0.0
 
bool channelMature = channelActive and breakoutDir == 0
 
if channelMature and barstate.isconfirmed and isWarmedUp
    float upper     = lineAt(hiX1, hiY1, hiX2, hiY2, float(bar_index))
    float lower     = lineAt(loX1, loY1, loX2, loY2, float(bar_index))
    float bodyLen   = math.abs(close - open)
    float candleLen = high - low
    float bodyRatio = candleLen > 0 ? bodyLen / candleLen : 0.0
    float bodyMid   = (open + close) / 2.0
    bool  volOk     = hasVolume ? volume > volSma * volConfirmInput : true
    bool  momOk     = momentumFilterInput ? (close > upper ? rsiVal > 50 : rsiVal < 50) : true
 
    if close > upper
        rawBullBreak := true
        breakBoundary := upper
        oppBoundary   := lower
        float penetration = atr20 > 0 ? math.min((close - upper) / atr20, 2.0) / 2.0 : 0.5
        float bodyCommit  = bodyMid > upper ? 1.0 : 0.3
        float volBonus    = volOk ? 1.0 : 0.4
        float momBonus    = momOk ? 1.0 : 0.4
        strengthScore := (penetration * 0.25 + bodyRatio * 0.15 + bodyCommit * 0.15 + volBonus * 0.25 + momBonus * 0.20) * 100
 
    if close < lower
        rawBearBreak := true
        breakBoundary := lower
        oppBoundary   := upper
        float penetration = atr20 > 0 ? math.min((lower - close) / atr20, 2.0) / 2.0 : 0.5
        float bodyCommit  = bodyMid < lower ? 1.0 : 0.3
        float volBonus    = volOk ? 1.0 : 0.4
        float momBonus    = momOk ? 1.0 : 0.4
        strengthScore := (penetration * 0.25 + bodyRatio * 0.15 + bodyCommit * 0.15 + volBonus * 0.25 + momBonus * 0.20) * 100
 
// ══════════════════════════════════════
// DIRECTIONAL SCORING
// ══════════════════════════════════════
if channelActive and breakoutDir == 0
    float upperNow  = lineAt(hiX1, hiY1, hiX2, hiY2, float(bar_index))
    float lowerNow  = lineAt(loX1, loY1, loX2, loY2, float(bar_index))
    float upperPrev = lineAt(hiX1, hiY1, hiX2, hiY2, float(bar_index - 1))
    float lowerPrev = lineAt(loX1, loY1, loX2, loY2, float(bar_index - 1))
    float midSlope  = ((upperNow + lowerNow) - (upperPrev + lowerPrev)) / 2.0
    float slopeBias = midSlope > 0 ? math.min(midSlope / (atr20 * 0.01), 1.0) : math.max(midSlope / (atr20 * 0.01), -1.0)
    float rsiBias   = (rsiVal - 50.0) / 50.0
    float chRange   = upperNow - lowerNow
    float posInCh   = chRange > 0 ? (close - lowerNow) / chRange : 0.5
    float posBias   = (posInCh - 0.5) * 2.0
    float combined  = slopeBias * 0.35 + rsiBias * 0.35 + posBias * 0.30
    dirScore := math.max(0.0, math.min(100.0, 50.0 + combined * 50.0))
 
if not channelActive
    dirScore := 50.0
 
// ══════════════════════════════════════
// SIGNAL PROCESSING + TARGET
// ══════════════════════════════════════
bool confirmedBullBreak = rawBullBreak
bool confirmedBearBreak = rawBearBreak
 
if confirmedBullBreak
    breakoutDir   := 1
    breakoutBar   := bar_index
    entryPrice    := close
    slPrice       := oppBoundary - atr20 * slPaddingInput
    slPriceOrig   := slPrice
    targetPrice   := breakBoundary + chMaxWidth
    float fullMove = math.abs(targetPrice - entryPrice)
    tp1Price      := entryPrice + fullMove / 3.0
    tp2Price      := entryPrice + fullMove * 2.0 / 3.0
    tp3Price      := entryPrice + fullMove
    tp1Hit := false
    tp2Hit := false
    tp3Hit := false
    slHit  := false
    tradeOpen := true
    breakInvalid  := false
    breakStrength := strengthScore >= 65 ? "Strong" : strengthScore >= 35 ? "Medium" : "Weak"
    bullBreaks    += 1
    lastSignalBar  := bar_index
    lastSignalType := "Bull Break"
    if not na(chHiLine)
        line.set_color(chHiLine, color.new(bullColorInput, 40))
        line.set_width(chHiLine, lineWidthInput)
    if not na(chLoLine)
        line.set_color(chLoLine, color.new(bullColorInput, 40))
        line.set_width(chLoLine, lineWidthInput)
    if not na(chFill)
        linefill.set_color(chFill, showFillInput ? BULL_BG : color.new(bullColorInput, 100))
 
if confirmedBearBreak
    breakoutDir   := -1
    breakoutBar   := bar_index
    entryPrice    := close
    slPrice       := oppBoundary + atr20 * slPaddingInput
    slPriceOrig   := slPrice
    targetPrice   := breakBoundary - chMaxWidth
    float fullMove = math.abs(entryPrice - targetPrice)
    tp1Price      := entryPrice - fullMove / 3.0
    tp2Price      := entryPrice - fullMove * 2.0 / 3.0
    tp3Price      := entryPrice - fullMove
    tp1Hit := false
    tp2Hit := false
    tp3Hit := false
    slHit  := false
    tradeOpen := true
    breakInvalid  := false
    breakStrength := strengthScore >= 65 ? "Strong" : strengthScore >= 35 ? "Medium" : "Weak"
    bearBreaks    += 1
    lastSignalBar  := bar_index
    lastSignalType := "Bear Break"
    if not na(chHiLine)
        line.set_color(chHiLine, color.new(bearColorInput, 40))
        line.set_width(chHiLine, lineWidthInput)
    if not na(chLoLine)
        line.set_color(chLoLine, color.new(bearColorInput, 40))
        line.set_width(chLoLine, lineWidthInput)
    if not na(chFill)
        linefill.set_color(chFill, showFillInput ? BEAR_BG : color.new(bearColorInput, 100))
 
// ══════════════════════════════════════
// CHANNEL TIMEOUT (no invalidation — trade closes only by SL or TP3)
// ══════════════════════════════════════
// Channel stays active while trade is open; timeout only applies to channels without a breakout
if channelActive and breakoutDir == 0 and bar_index - chDetectBar > maxChannelBarsInput
    channelActive := false
 
// ══════════════════════════════════════
// TP / SL HIT DETECTION (unified)
// ══════════════════════════════════════
if tradeOpen and breakoutDir != 0 and not slHit and bar_index > breakoutBar
    // Determine direction-agnostic hit conditions
    bool tpSide   = breakoutDir == 1 ? high >= tp1Price : low <= tp1Price
    bool tp2Side  = breakoutDir == 1 ? high >= tp2Price : low <= tp2Price
    bool tp3Side  = breakoutDir == 1 ? high >= tp3Price : low <= tp3Price
    bool slSide   = breakoutDir == 1 ? low <= slPrice  : high >= slPrice
 
    // [FIX 1.3] SL priority: check SL first on same-bar conflict
    if slSide
        slHit := true
        if not tp1Hit
            lossCount += 1
    else
        if tpSide and not tp1Hit
            tp1Hit := true
            winCount += 1
            // Trailing SL: move to breakeven after TP1
            // Keep original SL line in place, just update Entry label to indicate BE
            slPrice := entryPrice
            if not na(lbEntry)
                label.set_text(lbEntry, "ENTRY " + str.tostring(entryPrice, format.mintick) + " (SL → BE)")
            if not na(lbSL)
                string slPctUpd = str.tostring(math.abs(slPriceOrig - entryPrice) / entryPrice * 100, "#.##") + "%"
                label.set_text(lbSL, "SL orig " + str.tostring(slPriceOrig, format.mintick) + " (−" + slPctUpd + ")")
                label.set_color(lbSL, color.new(SL_COLOR, 75))
        if tp2Side and not tp2Hit
            tp2Hit := true
        if tp3Side and not tp3Hit
            tp3Hit := true
 
// Close trade when TP3 or SL reached
var int lastBreakoutDir = 0
if tradeOpen and (tp3Hit or slHit)
    lastBreakoutDir := breakoutDir
    tradeOpen := false
    channelActive := false
    breakoutDir   := 0
    // In "Last Only" mode, keep all drawings visible (pattern + trade)
    // They'll be cleaned up when the next pattern is detected
    if not showLastOnly
        // "All" mode: only delete labels, keep lines for history
        deleteTradeLabels()
        hasTradeDrawings := false
 
// ══════════════════════════════════════
// TRADE LEVEL LINES & LABELS
// ══════════════════════════════════════
if (confirmedBullBreak or confirmedBearBreak) and showLevelsInput
    if showLastOnly
        deleteTradeDrawings()
    else
        deleteTradeLabels()
 
    int lineEnd  = bar_index + labelOffsetInput
    int labelPos = bar_index + labelOffsetInput + 2
 
    lineEntry := line.new(bar_index, entryPrice, lineEnd, entryPrice, color = ENTRY_COLOR, width = 2, style = line.style_solid)
    lineSL    := line.new(bar_index, slPrice, lineEnd, slPrice, color = SL_COLOR, width = 2, style = line.style_solid)
    lineTP1   := line.new(bar_index, tp1Price, lineEnd, tp1Price, color = TP_COLOR, width = 1, style = line.style_dashed)
    lineTP2   := line.new(bar_index, tp2Price, lineEnd, tp2Price, color = TP_COLOR, width = 1, style = line.style_dashed)
    lineTP3   := line.new(bar_index, tp3Price, lineEnd, tp3Price, color = TP_COLOR, width = 2, style = line.style_dashed)
 
    string slPctStr  = str.tostring(math.abs(slPrice - entryPrice) / entryPrice * 100, "#.##") + "%"
    string tp1PctStr = str.tostring(math.abs(tp1Price - entryPrice) / entryPrice * 100, "#.##") + "%"
    string tp2PctStr = str.tostring(math.abs(tp2Price - entryPrice) / entryPrice * 100, "#.##") + "%"
    string tp3PctStr = str.tostring(math.abs(tp3Price - entryPrice) / entryPrice * 100, "#.##") + "%"
 
    lbEntry := label.new(labelPos, entryPrice, "ENTRY " + str.tostring(entryPrice, format.mintick), color = color.new(ENTRY_COLOR, 60), textcolor = TEXT_BRIGHT, style = label.style_label_left, size = size.small)
    lbSL    := label.new(labelPos, slPrice, "SL " + str.tostring(slPrice, format.mintick) + " (−" + slPctStr + ")", color = color.new(SL_COLOR, 60), textcolor = TEXT_BRIGHT, style = label.style_label_left, size = size.small)
    lbTP1   := label.new(labelPos, tp1Price, "TP1 " + str.tostring(tp1Price, format.mintick) + " (+" + tp1PctStr + ")", color = color.new(TP_COLOR, 60), textcolor = TEXT_BRIGHT, style = label.style_label_left, size = size.small)
    lbTP2   := label.new(labelPos, tp2Price, "TP2 " + str.tostring(tp2Price, format.mintick) + " (+" + tp2PctStr + ")", color = color.new(TP_COLOR, 60), textcolor = TEXT_BRIGHT, style = label.style_label_left, size = size.small)
    lbTP3   := label.new(labelPos, tp3Price, "TP3 " + str.tostring(tp3Price, format.mintick) + " (+" + tp3PctStr + ")", color = color.new(TP_COLOR, 60), textcolor = TEXT_BRIGHT, style = label.style_label_left, size = size.small)
    hasTradeDrawings := true
 
    // [FIX 1.6.2] In "Last Only" mode, push trade drawings into hist arrays
    // so they get cleaned up automatically when the next pattern is detected
    if showLastOnly
        histLines.push(lineEntry)
        histLines.push(lineSL)
        histLines.push(lineTP1)
        histLines.push(lineTP2)
        histLines.push(lineTP3)
        histLabels.push(lbEntry)
        histLabels.push(lbSL)
        histLabels.push(lbTP1)
        histLabels.push(lbTP2)
        histLabels.push(lbTP3)
 
// Dynamic update: extend lines and move labels every bar (only while trade is active or in Last Only with drawings)
if showLevelsInput and hasTradeDrawings and not (confirmedBullBreak or confirmedBearBreak)
    // [FIX 1.6.2] In "Last Only" mode, stop extending lines once trade is closed
    bool shouldExtend = tradeOpen
    if shouldExtend
        int newLineEnd  = bar_index + labelOffsetInput
        int newLabelPos = bar_index + labelOffsetInput + 2
        if not na(lineEntry)
            line.set_x2(lineEntry, newLineEnd)
        if not na(lineSL)
            line.set_x2(lineSL, newLineEnd)
        if not na(lineTP1)
            line.set_x2(lineTP1, newLineEnd)
        if not na(lineTP2)
            line.set_x2(lineTP2, newLineEnd)
        if not na(lineTP3)
            line.set_x2(lineTP3, newLineEnd)
        if not na(lbEntry)
            label.set_x(lbEntry, newLabelPos)
        if not na(lbSL)
            label.set_x(lbSL, newLabelPos)
        if not na(lbTP1)
            label.set_x(lbTP1, newLabelPos)
        if not na(lbTP2)
            label.set_x(lbTP2, newLabelPos)
        if not na(lbTP3)
            label.set_x(lbTP3, newLabelPos)
 
// Color TP lines when hit + update labels with checkmark
if showLevelsInput and not na(lineTP1) and tp1Hit
    line.set_color(lineTP1, TP_HIT_COLOR)
    line.set_style(lineTP1, line.style_solid)
    if not na(lbTP1) and tp1Hit and not tp1Hit[1]
        string tp1PctUpd = str.tostring(math.abs(tp1Price - entryPrice) / entryPrice * 100, "#.##") + "%"
        label.set_text(lbTP1, "TP1 ✓ " + str.tostring(tp1Price, format.mintick) + " (+" + tp1PctUpd + ")")
        label.set_color(lbTP1, color.new(TP_HIT_COLOR, 60))
if showLevelsInput and not na(lineTP2) and tp2Hit
    line.set_color(lineTP2, TP_HIT_COLOR)
    line.set_style(lineTP2, line.style_solid)
    if not na(lbTP2) and tp2Hit and not tp2Hit[1]
        string tp2PctUpd = str.tostring(math.abs(tp2Price - entryPrice) / entryPrice * 100, "#.##") + "%"
        label.set_text(lbTP2, "TP2 ✓ " + str.tostring(tp2Price, format.mintick) + " (+" + tp2PctUpd + ")")
        label.set_color(lbTP2, color.new(TP_HIT_COLOR, 60))
if showLevelsInput and not na(lineTP3) and tp3Hit
    line.set_color(lineTP3, TP_HIT_COLOR)
    line.set_style(lineTP3, line.style_solid)
    if not na(lbTP3) and tp3Hit and not tp3Hit[1]
        string tp3PctUpd = str.tostring(math.abs(tp3Price - entryPrice) / entryPrice * 100, "#.##") + "%"
        label.set_text(lbTP3, "TP3 ✓ " + str.tostring(tp3Price, format.mintick) + " (+" + tp3PctUpd + ")")
        label.set_color(lbTP3, color.new(TP_HIT_COLOR, 60))
if showLevelsInput and not na(lbSL) and slHit and not slHit[1]
    string slPctUpd = str.tostring(math.abs(slPriceOrig - entryPrice) / entryPrice * 100, "#.##") + "%"
    label.set_text(lbSL, "SL ✗ " + str.tostring(slPriceOrig, format.mintick) + " (−" + slPctUpd + ")")
 
// ══════════════════════════════════════
// SIGNAL VISUALS
// ══════════════════════════════════════
if showSignalsInput and confirmedBullBreak
    label sigLbl = label.new(bar_index, low, "Long", color = bullColorInput, textcolor = color.white, style = label.style_label_up, size = lblSize, tooltip = "Bull Breakout [" + breakStrength + "]")
    histLabels.push(sigLbl)
 
if showSignalsInput and confirmedBearBreak
    label sigLbl = label.new(bar_index, high, "Short", color = bearColorInput, textcolor = color.white, style = label.style_label_down, size = lblSize, tooltip = "Bear Breakout [" + breakStrength + "]")
    histLabels.push(sigLbl)
 
bgcolor(showBgInput and channelActive and breakoutDir == 0 ? color.new(lineColorInput, BG_TRANSPARENCY) : na, title = "Active Pattern BG")
 
// ══════════════════════════════════════
// DASHBOARD
// ══════════════════════════════════════
if showDashInput and barstate.islast
    var dashTable = table.new(dashPos, 2, 14, TABLE_BG, TABLE_BORDER, 1, TABLE_BORDER, 1)
 
    table.cell(dashTable, 0, 0, "WillyAlgoTrader", text_color = color.white, bgcolor = HEADER_BG, text_size = size.small, text_halign = text.align_center)
    table.merge_cells(dashTable, 0, 0, 1, 0)
 
    // Row 1: Pattern
    string patternStr = channelActive ? (breakoutDir == 0 ? "Active" : (breakoutDir == 1 ? "Bull Break" : "Bear Break")) : (tradeOpen ? (breakoutDir == 1 ? "Bull Trade" : "Bear Trade") : "Scanning")
    color patternColor = channelActive ? (breakoutDir == 0 ? TEXT_COLOR : (breakoutDir == 1 ? DASH_GREEN : DASH_RED)) : (tradeOpen ? (breakoutDir == 1 ? DASH_GREEN : DASH_RED) : TEXT_MUTED)
    table.cell(dashTable, 0, 1, "Pattern", text_color = TEXT_MUTED, text_size = dashSize, text_halign = text.align_left)
    table.cell(dashTable, 1, 1, patternStr, text_color = patternColor, text_size = dashSize, text_halign = text.align_right)
 
    // Row 2: Strength
    color strengthColor = breakStrength == "Strong" ? DASH_GREEN : breakStrength == "Medium" ? TEXT_COLOR : breakStrength == "Weak" ? DASH_RED : TEXT_MUTED
    string strengthDisp = breakStrength != "—" ? breakStrength : "—"
    table.cell(dashTable, 0, 2, "Strength", text_color = TEXT_MUTED, text_size = dashSize, text_halign = text.align_left)
    table.cell(dashTable, 1, 2, strengthDisp, text_color = strengthDisp != "—" ? strengthColor : TEXT_MUTED, text_size = dashSize, text_halign = text.align_right)
 
    // Row 3: Trade status
    string tradeStr = not tradeOpen and breakoutDir == 0 ? "No Trade" : slHit ? "SL Hit" : tp3Hit ? "TP3 ✓" : tp2Hit ? "TP2 ✓" : tp1Hit ? "TP1 ✓ (BE)" : tradeOpen ? "Active" : "No Trade"
    color tradeCol = slHit ? DASH_RED : tp3Hit ? DASH_CYAN : tp2Hit ? DASH_GREEN : tp1Hit ? DASH_GREEN : tradeOpen ? TEXT_COLOR : TEXT_MUTED
    table.cell(dashTable, 0, 3, "Trade", text_color = TEXT_MUTED, text_size = dashSize, text_halign = text.align_left)
    table.cell(dashTable, 1, 3, tradeStr, text_color = tradeCol, text_size = dashSize, text_halign = text.align_right)
 
    // Row 4: Bull Prob (moved up from row 13)
    string scoreStr = channelActive and breakoutDir == 0 ? str.tostring(dirScore, "#.0") + "%" : "—"
    color scoreColor = dirScore > 60 ? DASH_GREEN : dirScore < 40 ? DASH_RED : TEXT_COLOR
    table.cell(dashTable, 0, 4, "Bull Prob", text_color = TEXT_MUTED, text_size = dashSize, text_halign = text.align_left)
    table.cell(dashTable, 1, 4, scoreStr, text_color = channelActive and breakoutDir == 0 ? scoreColor : TEXT_MUTED, text_size = dashSize, text_halign = text.align_right)
 
    // Row 5: R:R ratio
    float rrRatio = tradeOpen or lastSignalBar > 0 ? safeDiv(math.abs(tp1Price - entryPrice), math.abs(slPriceOrig - entryPrice), 0.0) : 0.0
    string rrStr = rrRatio > 0 ? "1:" + str.tostring(rrRatio, "#.##") : "—"
    color rrColor = rrRatio >= 2.0 ? DASH_GREEN : rrRatio >= 1.0 ? TEXT_COLOR : rrRatio > 0 ? DASH_RED : TEXT_MUTED
    table.cell(dashTable, 0, 5, "R:R (TP1)", text_color = TEXT_MUTED, text_size = dashSize, text_halign = text.align_left)
    table.cell(dashTable, 1, 5, rrStr, text_color = rrColor, text_size = dashSize, text_halign = text.align_right)
 
    // Row 6: Touches
    string touchStr = channelActive ? str.tostring(chHiTouches) + "H / " + str.tostring(chLoTouches) + "L" : "—"
    table.cell(dashTable, 0, 6, "Touches", text_color = TEXT_MUTED, text_size = dashSize, text_halign = text.align_left)
    table.cell(dashTable, 1, 6, touchStr, text_color = channelActive ? TEXT_COLOR : TEXT_MUTED, text_size = dashSize, text_halign = text.align_right)
 
    // Row 7: Vol Contraction
    string volCStr = channelActive ? str.tostring(volContraction * 100, "#.0") + "%" : "—"
    color volCColor = volContraction < 0.7 ? DASH_GREEN : volContraction < VOL_CONTR_THRESH ? TEXT_COLOR : DASH_RED
    table.cell(dashTable, 0, 7, "Vol Contr", text_color = TEXT_MUTED, text_size = dashSize, text_halign = text.align_left)
    table.cell(dashTable, 1, 7, channelActive ? volCStr : "—", text_color = channelActive ? volCColor : TEXT_MUTED, text_size = dashSize, text_halign = text.align_right)
 
    // Row 8: Convergence
    string convStr = channelActive ? str.tostring(chConvergence * 100, "#.0") + "%" : "—"
    table.cell(dashTable, 0, 8, "Converge", text_color = TEXT_MUTED, text_size = dashSize, text_halign = text.align_left)
    table.cell(dashTable, 1, 8, convStr, text_color = channelActive ? TEXT_COLOR : TEXT_MUTED, text_size = dashSize, text_halign = text.align_right)
 
    // Row 9: Patterns
    table.cell(dashTable, 0, 9, "Patterns", text_color = TEXT_MUTED, text_size = dashSize, text_halign = text.align_left)
    table.cell(dashTable, 1, 9, str.tostring(totalPatterns), text_color = TEXT_COLOR, text_size = dashSize, text_halign = text.align_right)
 
    // Row 10: Breaks
    string breakStr = str.tostring(bullBreaks) + "▲ " + str.tostring(bearBreaks) + "▼"
    table.cell(dashTable, 0, 10, "Breaks", text_color = TEXT_MUTED, text_size = dashSize, text_halign = text.align_left)
    table.cell(dashTable, 1, 10, breakStr, text_color = TEXT_COLOR, text_size = dashSize, text_halign = text.align_right)
 
    // Row 11: Win Rate
    int totalClosed = winCount + lossCount
    string wrStr = totalClosed > 0 ? str.tostring(safeDiv(float(winCount), float(totalClosed), 0.0) * 100, "#.0") + "%" : "—"
    color wrColor = totalClosed > 0 ? (safeDiv(float(winCount), float(totalClosed), 0.0) >= 0.5 ? DASH_GREEN : DASH_RED) : TEXT_MUTED
    table.cell(dashTable, 0, 11, "Win Rate", text_color = TEXT_MUTED, text_size = dashSize, text_halign = text.align_left)
    table.cell(dashTable, 1, 11, wrStr, text_color = wrColor, text_size = dashSize, text_halign = text.align_right)
 
    // Row 12: Last Signal
    string sigAgoStr = lastSignalBar > 0 ? str.tostring(bar_index - lastSignalBar) + " bars" : "—"
    string sigDisplay = lastSignalType != "—" ? lastSignalType + " (" + sigAgoStr + ")" : "—"
    color sigColor = lastSignalType == "Bull Break" ? DASH_GREEN : lastSignalType == "Bear Break" ? DASH_RED : TEXT_MUTED
    table.cell(dashTable, 0, 12, "Last Sig", text_color = TEXT_MUTED, text_size = dashSize, text_halign = text.align_left)
    table.cell(dashTable, 1, 12, sigDisplay, text_color = sigColor, text_size = dashSize, text_halign = text.align_right)
 
    // Row 13: Version + TF
    table.cell(dashTable, 0, 13, timeframe.period, text_color = TEXT_MUTED, text_size = dashSize, text_halign = text.align_left, bgcolor = TABLE_ROW_ALT)
    table.cell(dashTable, 1, 13, INDICATOR_VERSION, text_color = TEXT_MUTED, text_size = dashSize, text_halign = text.align_right, bgcolor = TABLE_ROW_ALT)
 
// ══════════════════════════════════════
// WATERMARK
// ══════════════════════════════════════
if barstate.islast and showWatermarkInput
    var wmTable = table.new(position = position.bottom_center, columns = 1, rows = 1, bgcolor = color.new(color.black, 100), border_color = color.new(color.black, 100), border_width = 0, frame_color = color.new(color.black, 100), frame_width = 0)
    table.cell(wmTable, 0, 0, "WillyAlgoTrader", text_color = WM_COLOR, text_size = size.normal, text_halign = text.align_center, bgcolor = color.new(color.black, 100))
 
// ══════════════════════════════════════
// ALERTS (unified via formatAlertMsg)
// ══════════════════════════════════════
if confirmedBullBreak
    alert(formatAlertMsg("buy", "🟢", breakStrength, entryPrice, slPrice, tp1Price, tp2Price, tp3Price), alert.freq_once_per_bar_close)
 
if confirmedBearBreak
    alert(formatAlertMsg("sell", "🔴", breakStrength, entryPrice, slPrice, tp1Price, tp2Price, tp3Price), alert.freq_once_per_bar_close)
 
if channelJustDetected
    string tickerStr = syminfo.tickerid
    string tfStr = timeframe.period
    string priceStr = str.tostring(close, format.mintick)
    string jsonMsg = '{"action":"pattern","ticker":"' + tickerStr + '","price":' + priceStr + ',"tf":"' + tfStr + '"}'
    string textMsg = "🟡 PATTERN DETECTED | " + tickerStr + " | TF: " + tfStr + " | Price: " + priceStr
    alert(webhookInput ? jsonMsg : textMsg, alert.freq_once_per_bar_close)
 
if tp1Hit and not tp1Hit[1]
    alert(formatEventMsg("tp1_hit", "🎯"), alert.freq_once_per_bar_close)
 
if tp2Hit and not tp2Hit[1]
    alert(formatEventMsg("tp2_hit", "🎯"), alert.freq_once_per_bar_close)
 
if tp3Hit and not tp3Hit[1]
    alert(formatEventMsg("tp3_hit", "🏆"), alert.freq_once_per_bar_close)
 
if slHit and not slHit[1]
    alert(formatEventMsg("sl_hit", "🛑"), alert.freq_once_per_bar_close)

← 返回列表