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