Liquidity Pools Pro [WillyAlgoTrader]

WillyAlgoTrader · study · 1156 行 · 点赞 7,898 · TradingView 原页

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

Pine Script

//@version=6
// ══════════════════════════════════════════════════════════════════════════
// LIQUIDITY POOLS PRO [WillyAlgoTrader]
// ══════════════════════════════════════════════════════════════════════════
// Author:  Willy | WillyAlgoTrader
// Version: 1.1.5
 
indicator(
     title              = "Liquidity Pools Pro [WillyAlgoTrader]",
     shorttitle         = "LP Pro",
     overlay            = true,
     max_lines_count    = 500,
     max_labels_count   = 500,
     max_boxes_count    = 500,
     max_bars_back      = 5000)
 
// ══════════════════════════════════════════════════════════════════════════
// 1. CONSTANTS
// ══════════════════════════════════════════════════════════════════════════
GRP_DETECT  = "🎯 Liquidity Detection"
GRP_SCORE   = "💪 Strength Scoring"
GRP_SIGNAL  = "📡 Sweep Signals"
GRP_HTF     = "🔭 HTF Confluence"
GRP_RISK    = "🛡️ Risk Management"
GRP_VISUAL  = "🎨 Visual"
GRP_DASH    = "📊 Dashboard"
GRP_ALERT   = "🔔 Alerts"
GRP_COLORS  = "🎨 Colors"
 
INDICATOR_VERSION = "v1.1.5"
 
// Zone states
int STATE_ACTIVE    = 0
int STATE_SWEPT     = 1
int STATE_MITIGATED = 2
 
// ══════════════════════════════════════════════════════════════════════════
// 2. INPUTS
// ══════════════════════════════════════════════════════════════════════════
 
// ── Detection ─────────────────────────────────────────────────────────────
adaptivePivotInput = input.bool(true, "Adaptive Pivot Length", group = GRP_DETECT,
     tooltip = "When ON: pivot length adapts to volatility (ATR-driven).\n• High volatility → longer pivots (less noise)\n• Low volatility → shorter pivots (more responsive)\nWhen OFF: uses Manual Pivot Length below.")
 
manualPivotLenInput = input.int(8, "Manual Pivot Length", minval = 2, maxval = 50, group = GRP_DETECT,
     tooltip = "Used when Adaptive Pivot is OFF. Bars on each side of a pivot.\n• 5–8: scalping/intraday\n• 10–15: swing\n• 20+: position trading")
 
rightConfirmInput = input.int(2, "Right Confirmation Bars", minval = 1, maxval = 10, group = GRP_DETECT,
     tooltip = "Bars to the right needed to confirm a pivot.\nLower = faster signals but more revisions.\nHigher = more reliable but lagging.")
 
atrToleranceInput = input.float(0.25, "Equality Tolerance (×ATR)", minval = 0.05, maxval = 2.0, step = 0.05, group = GRP_DETECT,
     tooltip = "Two pivots are 'equal' if their distance ≤ this × ATR.\n• 0.15: strict (tighter pools, fewer matches)\n• 0.25: balanced (recommended)\n• 0.50: loose (wider pools, more matches)\nWorks across instruments (BTC, EURUSD, indices) without re-tuning.")
 
maxLookbackInput = input.int(200, "Max Pivot Lookback (bars)", minval = 50, maxval = 1000, group = GRP_DETECT,
     tooltip = "Max bars to look back when matching a new pivot to historical pivots.\nHigher = catch older pools, slower compute.")
 
maxActivePoolsInput = input.int(40, "Max Active Pools", minval = 5, maxval = 100, group = GRP_DETECT,
     tooltip = "Maximum number of active (un-swept) liquidity pools tracked simultaneously.\nWeakest pools are pruned when exceeded.")
 
// ── Strength Scoring ──────────────────────────────────────────────────────
minStrengthShowInput = input.int(0, "Min Strength to Display", minval = 0, maxval = 100, group = GRP_SCORE,
     tooltip = "Pools with strength below this value are hidden.\n• 0: show all pools (recommended for visualization)\n• 30: filter out weak\n• 50+: only strong pools\nDoes NOT affect signal generation — see 'Min Pool Strength for Signal'.")
 
halfLifeBarsInput = input.int(150, "Strength Half-Life (bars)", minval = 20, maxval = 1000, group = GRP_SCORE,
     tooltip = "Number of bars after which a pool's age-strength halves.\nFresh pools score higher; old pools decay.\n• 50: aggressive decay\n• 150: balanced\n• 500: slow decay")
 
useVolumeWeightInput = input.bool(true, "Volume-Weighted Strength", group = GRP_SCORE,
     tooltip = "Add cumulative volume at level to strength score.\nDisable on instruments without reliable volume (some Forex).")
 
// ── Sweep Signals ─────────────────────────────────────────────────────────
enableSweepSignalsInput = input.bool(true, "Enable Sweep Signals (BUY/SELL)", group = GRP_SIGNAL,
     tooltip = "Generate BUY/SELL signals on liquidity sweep + reversal close.\nLogic: wick pierces level, then bar closes back inside (failed breakout).")
 
minStrengthSignalInput = input.int(25, "Min Pool Strength for Signal", minval = 0, maxval = 100, group = GRP_SIGNAL,
     tooltip = "Only generate sweep signals from pools with strength above this value.\nReference values:\n• Fresh 1-touch pool: ~35 strength\n• Fresh 2-touch pool: ~44\n• Fresh 3-touch pool: ~53\n• 25 (default): catches fresh single-touch pools\n• 40: requires 2+ touches\n• 60: only strong multi-touch + volume/HTF")
 
requireBodyReversalInput = input.bool(true, "Require Body Reversal", group = GRP_SIGNAL,
     tooltip = "ON: Bar close must be back inside (real reversal, recommended).\nOFF: Any wick sweep counts (more signals, more noise).")
 
// ── HTF Confluence ────────────────────────────────────────────────────────
useHtfConfluenceInput = input.bool(false, "Enable HTF Confluence", group = GRP_HTF,
     tooltip = "Boost strength of pools that align with higher-timeframe pivots.\nDisable for pure single-TF analysis.")
 
htfTimeframeInput = input.timeframe("240", "Higher Timeframe", group = GRP_HTF,
     tooltip = "Higher timeframe to check for pivot confluence (e.g., 240 = 4H, D = Daily).")
 
// ── Risk Management ───────────────────────────────────────────────────────
riskPresetInput = input.string("Balanced", "Risk Preset",
     options = ["Conservative", "Balanced", "Aggressive", "Scalping", "Custom"],
     group = GRP_RISK,
     tooltip = "Quick risk presets:\n• Conservative: SL 2.5×ATR, TP 1R/2R/4R\n• Balanced: SL 1.5×ATR, TP 1R/2R/3R\n• Aggressive: SL 1.0×ATR, TP 1.5R/2.5R/4R\n• Scalping: SL 0.8×ATR, TP 0.8R/1.5R/2R\n• Custom: use manual multipliers below")
 
atrLenRiskInput = input.int(14, "ATR Length (Risk)", minval = 5, maxval = 50, group = GRP_RISK,
     tooltip = "ATR period for stop-loss calculation.")
 
slMultInput  = input.float(1.5, "Custom SL ×ATR",  minval = 0.5, maxval = 5.0,  step = 0.1, group = GRP_RISK,
     tooltip = "Used when preset = Custom. SL distance as multiple of ATR.")
tp1MultInput = input.float(1.0, "Custom TP1 ×Risk", minval = 0.5, maxval = 5.0,  step = 0.1, group = GRP_RISK)
tp2MultInput = input.float(2.0, "Custom TP2 ×Risk", minval = 1.0, maxval = 10.0, step = 0.1, group = GRP_RISK)
tp3MultInput = input.float(3.0, "Custom TP3 ×Risk", minval = 1.5, maxval = 15.0, step = 0.1, group = GRP_RISK)
 
showSlTpInput       = input.bool(true,  "Show SL/TP Lines",  group = GRP_RISK)
showSlTpLabelsInput = input.bool(true,  "Show SL/TP Labels", group = GRP_RISK)
 
useBreakEvenInput = input.bool(true, "Break-Even After TP1", group = GRP_RISK,
     tooltip = "When TP1 is hit, move stop-loss to entry price (break-even).\nThe trailed SL replaces the original — trade closes at break-even\ninstead of original SL if price reverses.\nLets winners run to TP2/TP3 risk-free.")
 
showPctOnLabelsInput = input.bool(true, "Show % Distance on Labels", group = GRP_RISK,
     tooltip = "Append % distance from entry to SL/TP labels (e.g. 'SL 1.2300 (-0.36%)').\nHelps quick assessment of risk/reward without mental math.")
 
// ── Visual ────────────────────────────────────────────────────────────────
themeInput = input.string("Auto", "Theme",
     options = ["Auto", "Dark", "Light"],
     group = GRP_VISUAL,
     tooltip = "Chart color theme.\n• Auto: detect from chart background\n• Dark: optimized for dark backgrounds\n• Light: optimized for light backgrounds")
 
showZonesInput      = input.bool(true,  "Show Liquidity Zones",   group = GRP_VISUAL)
showStrengthBarInput= input.bool(true,  "Show Strength Bar",      group = GRP_VISUAL,
     tooltip = "Show colored intensity bar inside each zone reflecting strength score.")
showLabelsInput     = input.bool(true,  "Show Pool Labels",       group = GRP_VISUAL)
showVolumeInput     = input.bool(true,  "Show Volume in Labels",  group = GRP_VISUAL)
showSweptInput      = input.bool(true,  "Keep Swept Zones",       group = GRP_VISUAL,
     tooltip = "ON: dimmed swept zones remain on chart.\nOFF: zones are deleted on sweep.")
showSignalsInput    = input.bool(true,  "Show Buy/Sell Markers",  group = GRP_VISUAL)
showWatermarkInput  = input.bool(true,  "Show Watermark",         group = GRP_VISUAL)
 
labelFontSizeInput = input.string("Small", "SL/TP Label Font Size",
     options = ["Tiny", "Small", "Normal", "Large", "Huge"],
     group = GRP_VISUAL,
     tooltip = "Font size for SL/TP/Entry labels (price + percent values).\nDefault: Small. Use Tiny for compact look on busy charts.")
 
signalFontSizeInput = input.string("Small", "Signal Marker Size (Long/Short)",
     options = ["Tiny", "Small", "Normal", "Large", "Huge"],
     group = GRP_VISUAL,
     tooltip = "Font size for Long/Short signal markers below/above bars.\nDefault: Small. Use Normal/Large for better visibility on dense charts.")
 
// ── Dashboard ─────────────────────────────────────────────────────────────
showDashInput = input.bool(true, "Show Dashboard", group = GRP_DASH)
dashPosStr    = input.string("Top Right", "Position",
     options = ["Top Left", "Top Right", "Bottom Left", "Bottom Right", "Middle Right"],
     group = GRP_DASH)
 
// ── Alerts ────────────────────────────────────────────────────────────────
webhookInput = input.bool(false, "Webhook JSON Format", group = GRP_ALERT,
     tooltip = "Format alert messages as JSON for webhook integrations (autotrading bots).")
 
alertSlHitInput = input.bool(true,  "Alert on SL Hit",        group = GRP_ALERT,
     tooltip = "Send alert when stop-loss is hit.")
alertTpHitInput = input.bool(false, "Alert on TP Hits & BE",  group = GRP_ALERT,
     tooltip = "Send alerts for TP1/TP2/TP3 hits and break-even activation.\nDisabled by default (can be noisy).")
 
// ── Colors ────────────────────────────────────────────────────────────────
bullColorInput = input.color(#00E676, "Bullish (Demand) Color", group = GRP_COLORS)
bearColorInput = input.color(#FF5252, "Bearish (Supply) Color", group = GRP_COLORS)
 
// ══════════════════════════════════════════════════════════════════════════
// 3. THEME DETECTION & PALETTE
// ══════════════════════════════════════════════════════════════════════════
isDark = switch themeInput
    "Dark"  => true
    "Light" => false
    =>         color.r(chart.bg_color) < 128
 
// Base text
TEXT_COLOR = isDark ? #E0E0E0 : #1A1A1A
TEXT_MUTED = isDark ? color.new(#9E9E9E, 0) : color.new(#757575, 0)
 
// Adaptive signal text (on chart bg)
// Light variants are darkened from raw bull/bear/neutral values to ensure
// WCAG AA contrast (4.5:1+) on white chart backgrounds.
BULL_TEXT     = isDark ? #00E676 : #00695C   // deep teal-green on light (was #00A152, 3.38:1 marginal)
BEAR_TEXT     = isDark ? #FF5252 : #D32F2F   // 4.98:1 on white — passes AA
NEUTRAL_TEXT  = isDark ? #FFEB3B : #BF360C   // dark deep-orange on light bg (was #F57F17, 2.65:1 fail)
 
// Label text on signal backgrounds
BULL_LABEL_TEXT = #004D25  // dark green on bright green (contrast 7.8:1)
BEAR_LABEL_TEXT = #FFFFFF  // white on red
 
// Tables
TABLE_BG      = isDark ? color.new(#131722, 5)  : color.new(#FFFFFF, 5)
TABLE_BORDER  = isDark ? color.new(#2A2E39, 50) : color.new(#D0D0D0, 50)
TABLE_ROW_ALT = isDark ? color.new(#1C2030, 0)  : color.new(#F0F4F8, 0)
HEADER_BG     = color.new(#1565C0, 0)
HEADER_TEXT   = #FFFFFF
 
// SL / TP / Entry — Pro palette (muted-saturated, AFT-style)
// Pro style favors muted-saturated tones over neon brights.
// Lines stay readable but don't scream — emphasis comes from positioning.
// v1.1.4: TP switched from turquoise (#26A69A/#00796B) to Material green
// for the more universal "profit = green" semantic. Adaptive contrast preserved.
SL_COLOR    = isDark ? color.new(#EF5350, 0)  : color.new(#C62828, 0)
TP1_COLOR   = isDark ? color.new(#4CAF50, 25) : color.new(#2E7D32, 25)
TP2_COLOR   = isDark ? color.new(#4CAF50, 35) : color.new(#2E7D32, 35)
TP3_COLOR   = isDark ? color.new(#4CAF50, 0)  : color.new(#2E7D32, 0)   // TP3 full opacity for emphasis
ENTRY_COLOR = isDark ? color.new(#42A5F5, 0)  : color.new(#1565C0, 0)   // blue (already adaptive — kept as is)
 
// Pro accent + utility colors
TP_HIT_COLOR = isDark ? color.new(#26C6DA, 0) : color.new(#00838F, 0)   // soft cyan for hit TP
BE_COLOR     = isDark ? color.new(#FFA726, 0) : color.new(#BF360C, 0)   // amber/burnt-orange for break-even (light tuned for 5.60:1 on white)
// LABEL_TEXT — text color on tinted SL/TP/Entry/BE/TP_HIT label backgrounds.
// Adaptive: dark theme uses muted-saturated bg colors (lighter tones) → needs DARK text;
// light theme uses darker variants of same colors → white text works fine.
// Without this adaptation, dark-theme labels had contrast ratios as low as 1.94:1
// (BE amber + white) which fails WCAG AA.
LABEL_TEXT   = isDark ? #1A1A1A : #FFFFFF
 
// Watermark
WM_COLOR = isDark ? color.new(#FFFFFF, 80) : color.new(#000000, 80)
 
// Zone colors derived from inputs
BULL_ZONE_BORDER = bullColorInput
BEAR_ZONE_BORDER = bearColorInput
SWEPT_BORDER     = isDark ? color.new(#9E9E9E, 60) : color.new(#616161, 60)
SWEPT_BG         = isDark ? color.new(#9E9E9E, 92) : color.new(#9E9E9E, 88)
MITIGATED_BORDER = isDark ? color.new(#616161, 70) : color.new(#424242, 70)
MITIGATED_BG     = isDark ? color.new(#616161, 94) : color.new(#424242, 92)
 
// Label background (transparent so only text floats)
LABEL_BG_TRANSPARENT = color.new(color.black, 100)
 
// ══════════════════════════════════════════════════════════════════════════
// 4. RISK PRESET RESOLUTION
// ══════════════════════════════════════════════════════════════════════════
[effectiveSLMult, effectiveTP1Mult, effectiveTP2Mult, effectiveTP3Mult] = switch riskPresetInput
    "Conservative" => [2.5, 1.0, 2.0, 4.0]
    "Aggressive"   => [1.0, 1.5, 2.5, 4.0]
    "Scalping"     => [0.8, 0.8, 1.5, 2.0]
    "Custom"       => [slMultInput, tp1MultInput, tp2MultInput, tp3MultInput]
    =>                [1.5, 1.0, 2.0, 3.0]   // Balanced
 
// ══════════════════════════════════════════════════════════════════════════
// 5. DASHBOARD POSITION
// ══════════════════════════════════════════════════════════════════════════
dashPos = switch dashPosStr
    "Top Left"     => position.top_left
    "Top Right"    => position.top_right
    "Bottom Left"  => position.bottom_left
    "Bottom Right" => position.bottom_right
    "Middle Right" => position.middle_right
    =>                position.top_right
 
// ══════════════════════════════════════════════════════════════════════════
// 6. UTILITY FUNCTIONS
// ══════════════════════════════════════════════════════════════════════════
safeDiv(float num, float den, float fallback = 0.0) =>
    den != 0 and not na(num) and not na(den) ? num / den : fallback
 
// Format percent distance from entry, with sign. Returns "" if entry/level invalid
// or showPctOnLabelsInput is OFF. Used to suffix SL/TP labels.
formatPctFromEntry(float level, float entry) =>
    string res = ""
    if showPctOnLabelsInput and not na(level) and not na(entry) and entry != 0
        float pct = (level - entry) / entry * 100.0
        string sign = pct >= 0 ? "+" : ""
        res := " (" + sign + str.tostring(pct, "#.##") + "%)"
    res
 
// Resolve user font size string to size constant
sizeFromString(string s) =>
    switch s
        "Tiny"   => size.tiny
        "Small"  => size.small
        "Normal" => size.normal
        "Large"  => size.large
        "Huge"   => size.huge
        =>          size.small
 
formatVolume(float v) =>
    string res = "—"
    if not na(v)
        if v >= 1e9
            res := str.format("{0,number,#.##}B", v / 1e9)
        else if v >= 1e6
            res := str.format("{0,number,#.##}M", v / 1e6)
        else if v >= 1e3
            res := str.format("{0,number,#.#}K", v / 1e3)
        else
            res := str.tostring(v, "#")
    res
 
// Strength → color intensity. Strong = saturated; weak = transparent.
strengthToTransparency(float strength) =>
    // strength ∈ [0, 100]; map to transparency [88, 55]
    int t = int(88.0 - (math.max(0.0, math.min(100.0, strength)) * 0.33))
    t
 
// ══════════════════════════════════════════════════════════════════════════
// 7. ADAPTIVE PIVOT LENGTH
// ══════════════════════════════════════════════════════════════════════════
float atrFast = nz(ta.atr(14), 0.0)
float atrSlow = nz(ta.atr(50), 0.0)
 
// Volatility ratio: > 1 = current more volatile than avg → shorter pivot
float volRatio = safeDiv(atrFast, atrSlow, 1.0)
 
// Adaptive left length: 4 (high vol) ... 16 (low vol)
// Note: dynamic series-int length is supported by ta.pivothigh in v6,
// but we ensure max_bars_back covers the full possible range.
int adaptiveLeftLen = adaptivePivotInput ? int(math.round(math.max(4.0, math.min(16.0, 10.0 / math.max(0.5, volRatio))))) : manualPivotLenInput
 
int leftLen  = adaptiveLeftLen
int rightLen = rightConfirmInput
 
// Warmup
int WARMUP_BARS = math.max(50, atrLenRiskInput + 10)
bool isWarmedUp = bar_index >= WARMUP_BARS
 
// ══════════════════════════════════════════════════════════════════════════
// 8. TYPES
// ══════════════════════════════════════════════════════════════════════════
type LiquidityPool
    float    level         // representative price (avg of all touches)
    float    levelTop      // upper bound (level + atr*tolerance/2)
    float    levelBot      // lower bound
    int      lastTouchIdx   // bar index of most recent touch (for sweep cooldown + age)
    int      firstBarTime   // time of pool creation (for box/line drawing — avoids bar-distance limits)
    int      touchCount
    float    cumulativeVol  // sum of volume of bars whose high/low touched within tolerance
    bool     isHigh        // sell-side liquidity (highs) vs buy-side (lows)
    int      state         // STATE_ACTIVE / STATE_SWEPT / STATE_MITIGATED
    int      sweptAtIdx     // bar index of sweep (for prune ordering)
    int      sweptAtTime    // time of sweep (for box right edge)
    float    htfBonus      // 0 or +20 if HTF confluence
    float    strength      // 0–100 (cached, recomputed lazily)
    box      zoneBox
    label    poolLabel
    line     midLine
 
// ══════════════════════════════════════════════════════════════════════════
// 9. STORAGE
// ══════════════════════════════════════════════════════════════════════════
var array<LiquidityPool> pools = array.new<LiquidityPool>()
 
// Diagnostics counters (for Dashboard debugging — why no signals?)
var int totalMitigationsCount = 0  // any mitigation event regardless of strength
var int totalSweepsCount      = 0  // wick-only sweeps (didn't close back)
// signalsFiredCount counts GENERATED signals (passed strength + body filter), NOT executed
// trades. Position lock may block some from opening — but the count still increments.
// To debug "why no entry": compare with dashboard "Position" row.
var int signalsFiredCount     = 0
var int totalPoolsEverCount   = 0  // pools created since chart start
 
// ══════════════════════════════════════════════════════════════════════════
// 10. PIVOT DETECTION
// ══════════════════════════════════════════════════════════════════════════
float ph = ta.pivothigh(leftLen, rightLen)
float pl = ta.pivotlow(leftLen, rightLen)
 
// Equality tolerance — reuse atrFast (declared in section 7) instead of
// computing ta.atr(14) again. Same value, half the cost.
float tolerance = atrFast * atrToleranceInput
 
// HTF pivot levels (non-repainting)
// IMPORTANT: in Pine v6, short-circuit eval means functions inside ternaries
// may not run consistently. We always call request.security() and apply the
// useHtfConfluenceInput flag separately below.
float _htfPH = request.security(syminfo.tickerid, htfTimeframeInput, ta.pivothigh(3, 3)[1], barmerge.gaps_off, barmerge.lookahead_on)
float _htfPL = request.security(syminfo.tickerid, htfTimeframeInput, ta.pivotlow(3, 3)[1],  barmerge.gaps_off, barmerge.lookahead_on)
float htfPivotHigh = useHtfConfluenceInput ? _htfPH : na
float htfPivotLow  = useHtfConfluenceInput ? _htfPL : na
 
// ══════════════════════════════════════════════════════════════════════════
// 11. STRENGTH SCORING
// ══════════════════════════════════════════════════════════════════════════
// Score components (each 0–N pts, weighted), total clamped to 0–100:
//   • Touches:    (touchCount-1)*9, capped at 35 → 1=0, 2=9, 3=18, 4=27, 5+=35
//   • Recency:    age decay via half-life → up to 35 pts (fresh = 35)
//   • Volume:     cumVol vs median → up to 20 pts
//   • HTF:        bonus +20 if HTF confluent
//
// Examples:
//   Fresh 1-touch pool, no volume, no HTF:        0 + 35 +  0 +  0 = 35
//   Fresh 2-touch pool, no volume, no HTF:        9 + 35 +  0 +  0 = 44
//   Fresh 3-touch pool, 2x median volume, no HTF: 18 + 35 + 5.5 + 0 = 58.5
//   Old (1 half-life) 2-touch pool, no extras:    9 + 17.5 + 0 + 0 = 26.5
 
var float volMedian = na
 
method computeStrength(LiquidityPool p) =>
    int   ageBars    = bar_index - p.lastTouchIdx
    float decay      = math.pow(0.5, safeDiv(ageBars, halfLifeBarsInput, 0.0))
    // touchCount ≥ 1 always; (count-1)*9 capped at 35: 1→0, 2→9, 3→18, 4→27, 5+→35
    float touchPts   = math.min(35.0, (p.touchCount - 1) * 9.0)
    float recencyPts = 35.0 * decay
    float volPts     = 0.0
    if useVolumeWeightInput and not na(volMedian) and volMedian > 0
        float r = safeDiv(p.cumulativeVol, volMedian, 0.0)
        // log scaling: r=1 → 0 pts, r=2 → ~5.5 pts, r=10 → ~18.4 pts, r→∞ capped at 20
        volPts := math.min(20.0, math.log(math.max(1.0, r)) * 8.0)
    float total = touchPts + recencyPts + volPts + p.htfBonus
    p.strength := math.max(0.0, math.min(100.0, total))
    p.strength
 
// ══════════════════════════════════════════════════════════════════════════
// 12. POOL MATCHING & CREATION
// ══════════════════════════════════════════════════════════════════════════
// Try to match a new pivot to an existing pool. Returns pool index or -1.
findMatchingPool(float price, bool isHigh, float tol) =>
    int foundIdx = -1
    int sz = pools.size()
    if sz > 0
        for i = 0 to sz - 1
            LiquidityPool p = pools.get(i)
            if p.state == STATE_ACTIVE and p.isHigh == isHigh
                float dist = math.abs(price - p.level)
                if dist <= tol and (bar_index - p.lastTouchIdx) <= maxLookbackInput
                    foundIdx := i
                    break
    foundIdx
 
// Update existing pool with new touch. Returns the new touch count.
// Note: in Pine v6, UDT fields are reference-mutated; pools.set() is not needed.
updatePoolWithTouch(int poolIdx, float touchPrice, int touchIdx, float touchVol) =>
    LiquidityPool p = pools.get(poolIdx)
    // Recalculate level as weighted average (older touches retain memory)
    float newLevel = (p.level * p.touchCount + touchPrice) / (p.touchCount + 1)
    p.level         := newLevel
    p.levelTop      := newLevel + tolerance / 2.0
    p.levelBot      := newLevel - tolerance / 2.0
    p.lastTouchIdx  := touchIdx
    p.touchCount    := p.touchCount + 1
    p.cumulativeVol := p.cumulativeVol + touchVol
    p.touchCount   // return for Pine Script v6 (function must return a value)
 
// Create a new pool from a pivot (or single pivot if no match). Returns the new pool size.
// Uses xloc.bar_time for box/line so they remain valid when bar distance grows beyond max_bars_back.
// NOTE: cannot modify global counters from inside a function in Pine v6 — caller increments them.
createPool(float price, int idx, int t, float vol, bool isHigh, float htfBonus) =>
    color borderClr = isHigh ? BEAR_ZONE_BORDER : BULL_ZONE_BORDER
    color bgClr     = color.new(borderClr, 88)
    box  newBox  = box.new(t, price + tolerance / 2.0, t, price - tolerance / 2.0, border_color = borderClr, bgcolor = bgClr, border_width = 1, xloc = xloc.bar_time)
    line newLine = line.new(t, price, t, price, color = color.new(borderClr, 50), style = line.style_dotted, width = 1, xloc = xloc.bar_time)
    label newLbl = label.new(t, price, "", color = LABEL_BG_TRANSPARENT, textcolor = isHigh ? BEAR_TEXT : BULL_TEXT, style = label.style_label_left, size = size.small, xloc = xloc.bar_time)
    LiquidityPool p = LiquidityPool.new(price, price + tolerance / 2.0, price - tolerance / 2.0, idx, t, 1, vol, isHigh, STATE_ACTIVE, 0, 0, htfBonus, 0.0, newBox, newLbl, newLine)
    pools.push(p)
    pools.size()   // return value for Pine Script v6
 
// ══════════════════════════════════════════════════════════════════════════
// 13. PROCESS NEW PIVOTS
// ══════════════════════════════════════════════════════════════════════════
if not na(ph) and isWarmedUp
    int  pivotIdx  = bar_index - rightLen
    int  pivotTime = time[rightLen]
    float pVol     = nz(volume[rightLen], 0.0)
    // HTF confluence
    float htfBonus = useHtfConfluenceInput and not na(htfPivotHigh) and math.abs(ph - htfPivotHigh) <= tolerance * 1.5 ? 20.0 : 0.0
    int matchIdx = findMatchingPool(ph, true, tolerance)
    if matchIdx >= 0
        updatePoolWithTouch(matchIdx, ph, pivotIdx, pVol)
        LiquidityPool pp = pools.get(matchIdx)
        // HTF bonus accumulates if confluent again (UDT field assignment is by reference)
        if htfBonus > 0 and pp.htfBonus < 20.0
            pp.htfBonus := 20.0
    else
        createPool(ph, pivotIdx, pivotTime, pVol, true, htfBonus)
        totalPoolsEverCount := totalPoolsEverCount + 1
 
if not na(pl) and isWarmedUp
    int  pivotIdx  = bar_index - rightLen
    int  pivotTime = time[rightLen]
    float pVol     = nz(volume[rightLen], 0.0)
    float htfBonus = useHtfConfluenceInput and not na(htfPivotLow) and math.abs(pl - htfPivotLow) <= tolerance * 1.5 ? 20.0 : 0.0
    int matchIdx = findMatchingPool(pl, false, tolerance)
    if matchIdx >= 0
        updatePoolWithTouch(matchIdx, pl, pivotIdx, pVol)
        LiquidityPool pp = pools.get(matchIdx)
        if htfBonus > 0 and pp.htfBonus < 20.0
            pp.htfBonus := 20.0
    else
        createPool(pl, pivotIdx, pivotTime, pVol, false, htfBonus)
        totalPoolsEverCount := totalPoolsEverCount + 1
 
// Volume median for scoring — recomputed every 25 bars (cheap, ~40 pools)
// Initialize as soon as we have enough pools, then refresh periodically.
if pools.size() >= 3 and (na(volMedian) or bar_index % 25 == 0)
    array<float> vols = array.new<float>()
    for i = 0 to pools.size() - 1
        vols.push(pools.get(i).cumulativeVol)
    if vols.size() > 0
        volMedian := array.median(vols)
 
// ══════════════════════════════════════════════════════════════════════════
// 14. VOLUME-AT-LEVEL ACCUMULATION
// ══════════════════════════════════════════════════════════════════════════
// On every bar, if current bar's high or low touches an active pool's zone,
// add bar's volume to that pool's cumulative volume. This is a more accurate
// "liquidity at level" measure than just summing pivot-bar volumes.
// O(P) per bar where P = pool count (~40), negligible.
if pools.size() > 0 and isWarmedUp and barstate.isconfirmed and useVolumeWeightInput
    float barVol = nz(volume, 0.0)
    if barVol > 0
        for i = 0 to pools.size() - 1
            LiquidityPool p = pools.get(i)
            if p.state == STATE_ACTIVE
                bool touched = (high >= p.levelBot and high <= p.levelTop) or (low >= p.levelBot and low <= p.levelTop) or (low <= p.levelBot and high >= p.levelTop)
                if touched and bar_index > p.lastTouchIdx
                    // Don't double-count the pivot bar itself; only count other bars
                    p.cumulativeVol := p.cumulativeVol + barVol
 
// ══════════════════════════════════════════════════════════════════════════
// 15. SWEEP & MITIGATION DETECTION + SIGNAL GENERATION
// ══════════════════════════════════════════════════════════════════════════
// Pool state transitions:
//   STATE_ACTIVE → STATE_SWEPT     : wick pierced level but bar didn't close back
//                                    (level was broken — likely BoS / continuation)
//   STATE_ACTIVE → STATE_MITIGATED : wick pierced AND bar closed back inside
//                                    (real liquidity grab — failed breakout, reversal)
//
// Signal logic:
//   • Default (requireBodyReversalInput = ON): only fire on MITIGATED (proper SMC setup).
//   • Aggressive (requireBodyReversalInput = OFF): fire on any wick sweep — more signals,
//     more noise, but catches early reversals before the candle closes back.
//
// Notes:
// - Cooldown: skip pivot's own confirmation bars + 1 (so pool "settles" before sweep eligible).
// - signalsFiredCount is incremented only ONCE per bar (even if multiple pools fire).
 
bool sweepBuySignal  = false  // sweep of LOW + close above → BUY
bool sweepSellSignal = false  // sweep of HIGH + close below → SELL
float signalPoolStrength = 0.0
bool anySignalFiredThisBar = false  // dedup counter increment
 
if pools.size() > 0 and isWarmedUp and barstate.isconfirmed
    for i = 0 to pools.size() - 1
        LiquidityPool p = pools.get(i)
        if p.state == STATE_ACTIVE and bar_index >= p.lastTouchIdx + rightLen + 1
            float s = p.computeStrength()
 
            // Direction-agnostic wick/close logic
            bool wickSweep = p.isHigh ? high > p.levelTop : low < p.levelBot
            bool closeBack = p.isHigh ? close < p.level   : close > p.level
 
            if wickSweep
                // Classification: MITIGATED only if real reversal (close back inside).
                // Wick-only sweep without close-back means the level was broken (SWEPT).
                if closeBack
                    p.state       := STATE_MITIGATED
                    p.sweptAtIdx  := bar_index
                    p.sweptAtTime := time
                    totalMitigationsCount := totalMitigationsCount + 1
                else
                    p.state       := STATE_SWEPT
                    p.sweptAtIdx  := bar_index
                    p.sweptAtTime := time
                    totalSweepsCount := totalSweepsCount + 1
 
                // Signal: by default only on mitigation; if user disables body-reversal
                // requirement, fire on any wick sweep (caller takes responsibility).
                bool signalEligible = requireBodyReversalInput ? closeBack : true
                if enableSweepSignalsInput and signalEligible and s >= minStrengthSignalInput
                    if p.isHigh
                        sweepSellSignal := true
                    else
                        sweepBuySignal := true
                    if s > signalPoolStrength
                        signalPoolStrength := s
                    anySignalFiredThisBar := true
 
if anySignalFiredThisBar
    signalsFiredCount := signalsFiredCount + 1
 
// ══════════════════════════════════════════════════════════════════════════
// 16. PRUNE WEAK / EXCESS POOLS
// ══════════════════════════════════════════════════════════════════════════
// Strategy:
//   1. If "Keep Swept Zones" is OFF, immediately remove all non-active pools.
//   2. If size > maxActivePools, drop oldest swept first; then weakest active.
// Note: Pine doesn't allow reverse for-loops, and removing during forward
// iteration corrupts indices. Pattern: collect indices to delete, then remove
// from highest to lowest index.
if pools.size() > 0 and barstate.isconfirmed
    // Step 1: prune swept/mitigated pools if user opted out
    if not showSweptInput
        array<int> toRemove = array.new<int>()
        for i = 0 to pools.size() - 1
            LiquidityPool p = pools.get(i)
            if p.state != STATE_ACTIVE
                toRemove.push(i)
        // Remove highest-to-lowest so earlier indices stay valid
        if toRemove.size() > 0
            for k = 0 to toRemove.size() - 1
                int idx = toRemove.get(toRemove.size() - 1 - k)
                LiquidityPool p = pools.get(idx)
                box.delete(p.zoneBox)
                line.delete(p.midLine)
                label.delete(p.poolLabel)
                pools.remove(idx)
 
    // Step 2: enforce limit (single-pool drop per bar — sufficient since pivots are sparse)
    if pools.size() > maxActivePoolsInput
        // Try to drop oldest swept first
        int oldestSweptIdx = -1
        int oldestSweptIdxBar = bar_index + 1
        for i = 0 to pools.size() - 1
            LiquidityPool p = pools.get(i)
            if p.state != STATE_ACTIVE and p.sweptAtIdx < oldestSweptIdxBar
                oldestSweptIdxBar := p.sweptAtIdx
                oldestSweptIdx := i
        if oldestSweptIdx >= 0
            LiquidityPool victim = pools.get(oldestSweptIdx)
            box.delete(victim.zoneBox)
            line.delete(victim.midLine)
            label.delete(victim.poolLabel)
            pools.remove(oldestSweptIdx)
        else
            // No swept pools — drop weakest active
            int worstIdx = -1
            float worstStr = 1e9
            for i = 0 to pools.size() - 1
                LiquidityPool p = pools.get(i)
                if p.state == STATE_ACTIVE
                    float s = p.computeStrength()
                    if s < worstStr
                        worstStr := s
                        worstIdx := i
            if worstIdx >= 0
                LiquidityPool victim = pools.get(worstIdx)
                box.delete(victim.zoneBox)
                line.delete(victim.midLine)
                label.delete(victim.poolLabel)
                pools.remove(worstIdx)
 
// ══════════════════════════════════════════════════════════════════════════
// 17. RENDER POOLS (only on last bar — performance)
// ══════════════════════════════════════════════════════════════════════════
// Pools use xloc.bar_time for box/line/label drawables, so set_left/right/x
// accept TIME values (UNIX timestamps), not bar_index. This avoids the
// "bar index too far from current bar" error on long histories.
if barstate.islast and pools.size() > 0
    for i = 0 to pools.size() - 1
        LiquidityPool p = pools.get(i)
        float s = p.computeStrength()
        bool visible = showZonesInput and (p.state == STATE_ACTIVE ? s >= minStrengthShowInput : showSweptInput)
 
        // Always update geometry to current bar
        if visible
            int rightT = p.state == STATE_ACTIVE ? time : p.sweptAtTime
            box.set_left  (p.zoneBox, p.firstBarTime)
            box.set_right (p.zoneBox, rightT)
            box.set_top   (p.zoneBox, p.levelTop)
            box.set_bottom(p.zoneBox, p.levelBot)
            line.set_x1(p.midLine, p.firstBarTime)
            line.set_x2(p.midLine, rightT)
            line.set_y1(p.midLine, p.level)
            line.set_y2(p.midLine, p.level)
 
            color baseClr = p.isHigh ? BEAR_ZONE_BORDER : BULL_ZONE_BORDER
            int   bgTransp = showStrengthBarInput ? strengthToTransparency(s) : 88
            color borderClr = baseClr
            color bgClr     = color.new(baseClr, bgTransp)
 
            if p.state == STATE_SWEPT
                borderClr := SWEPT_BORDER
                bgClr     := SWEPT_BG
            else if p.state == STATE_MITIGATED
                borderClr := MITIGATED_BORDER
                bgClr     := MITIGATED_BG
 
            box.set_border_color(p.zoneBox, borderClr)
            box.set_bgcolor     (p.zoneBox, bgClr)
            line.set_color(p.midLine, color.new(borderClr, 40))
 
            if showLabelsInput
                string typeStr = p.isHigh ? "SSL" : "BSL"   // sell-side / buy-side liquidity
                string countStr = p.touchCount > 1 ? str.format(" ×{0}", p.touchCount) : ""
                string strStr   = str.format(" [{0}]", math.round(s))
                string volStr   = showVolumeInput ? " " + formatVolume(p.cumulativeVol) : ""
                string stateStr = p.state == STATE_SWEPT ? "Swept " : p.state == STATE_MITIGATED ? "Mit. " : ""
                label.set_x   (p.poolLabel, rightT)
                label.set_y   (p.poolLabel, p.level)
                label.set_text(p.poolLabel, stateStr + typeStr + countStr + strStr + volStr)
                label.set_textcolor(p.poolLabel, p.state == STATE_ACTIVE ? (p.isHigh ? BEAR_TEXT : BULL_TEXT) : color.new(TEXT_MUTED, 20))
            else
                label.set_text(p.poolLabel, "")
        else
            // Hide via collapsing geometry (use current bar time)
            box.set_left (p.zoneBox, time)
            box.set_right(p.zoneBox, time)
            line.set_x1(p.midLine, time)
            line.set_x2(p.midLine, time)
            label.set_text(p.poolLabel, "")
 
// ══════════════════════════════════════════════════════════════════════════
// 18. SIGNAL VISUAL MARKERS
// ══════════════════════════════════════════════════════════════════════════
// Using label.new (not plotshape) so the marker size can be dynamic
// (plotshape only accepts simple/const size values, label.new accepts series).
//
// Markers fire on the SAME bar as the signal — the trade opens at this bar's
// close, SL/TP lines appear immediately, and BUY/SELL alert is sent.
signalMarkerSize = sizeFromString(signalFontSizeInput)
 
if showSignalsInput and sweepBuySignal
    label.new(bar_index, low, "Long",
         color     = bullColorInput,
         textcolor = BULL_LABEL_TEXT,
         style     = label.style_label_up,
         size      = signalMarkerSize,
         yloc      = yloc.belowbar)
 
if showSignalsInput and sweepSellSignal
    label.new(bar_index, high, "Short",
         color     = bearColorInput,
         textcolor = BEAR_LABEL_TEXT,
         style     = label.style_label_down,
         size      = signalMarkerSize,
         yloc      = yloc.abovebar)
 
// ══════════════════════════════════════════════════════════════════════════
// 19. RISK MANAGEMENT — SL/TP CALCULATION & STATE
// ══════════════════════════════════════════════════════════════════════════
float riskAtr    = nz(ta.atr(atrLenRiskInput), 0.0)
float slDistance = riskAtr * effectiveSLMult
 
var float activeEntry = na
var float activeSL    = na
var float activeTP1   = na
var float activeTP2   = na
var float activeTP3   = na
var int   activeDir   = 0     // 1 long, -1 short, 0 flat
var float activePoolStrength = na
 
// Latched flags — fire each TP only once per trade, prevent duplicate alerts
var bool tp1Reached = false
var bool tp2Reached = false
var bool tp3Reached = false
 
// Break-even tracking — set when SL is moved to entry after TP1
var bool beActive = false
 
// Direction-agnostic resolved signals
// Position lock: while a trade is active, new signals are blocked.
// If we don't lock, a fresh signal in the opposite direction would silently
// overwrite the open position's SL/TP — bad for live trading.
//
// Special case: if the current bar would close the trade (SL or TP3 hit),
// we treat the position as "closing now" and allow signals through.
// The auto-reset block below clears state on this same bar, so the new
// signal can proceed cleanly.
bool barWouldCloseLong  = activeDir == 1  and not na(activeSL) and not na(activeTP3) and (low  <= activeSL or high >= activeTP3)
bool barWouldCloseShort = activeDir == -1 and not na(activeSL) and not na(activeTP3) and (high >= activeSL or low  <= activeTP3)
bool positionActive = activeDir != 0 and not barWouldCloseLong and not barWouldCloseShort
 
confirmedBuy  = sweepBuySignal  and barstate.isconfirmed and isWarmedUp and not positionActive
confirmedSell = sweepSellSignal and barstate.isconfirmed and isWarmedUp and not positionActive
 
// Track whether the trade was opened on the current bar.
// Prevents same-bar SL/TP detection on a freshly opened trade
// (would otherwise misfire if the bar's wick already crossed new SL/TP levels).
bool justOpened = false
 
if (confirmedBuy or confirmedSell)
    int  newDir = confirmedBuy ? 1 : -1
    float sign  = confirmedBuy ? 1.0 : -1.0
    activeEntry := close
    activeSL    := close - sign * slDistance
    activeTP1   := close + sign * slDistance * effectiveTP1Mult
    activeTP2   := close + sign * slDistance * effectiveTP2Mult
    activeTP3   := close + sign * slDistance * effectiveTP3Mult
    activeDir   := newDir
    activePoolStrength := signalPoolStrength
    tp1Reached  := false
    tp2Reached  := false
    tp3Reached  := false
    beActive    := false
    justOpened  := true
 
// Hit detection — single helper for all levels.
// Skipped on the same bar a trade opens (justOpened) — see comment above.
//
// Break-even handling: SL hit is checked against the SL value AT THE START
// of this bar (effectiveSL), not against any new BE level applied later in
// the same bar. This ensures TP1 + SL on the same bar resolves correctly:
// TP1 fires from the bar's wick, then SL moves to entry — but stop-out of
// THIS bar still uses original SL (not the BE level).
float effectiveSL = activeSL  // captured before any BE update on this bar
 
bool slHit  = not justOpened and (activeDir == 1  ? low  <= effectiveSL : activeDir == -1 ? high >= effectiveSL : false)
bool tp1Hit = not justOpened and (activeDir == 1  ? high >= activeTP1   : activeDir == -1 ? low  <= activeTP1   : false)
bool tp2Hit = not justOpened and (activeDir == 1  ? high >= activeTP2   : activeDir == -1 ? low  <= activeTP2   : false)
bool tp3Hit = not justOpened and (activeDir == 1  ? high >= activeTP3   : activeDir == -1 ? low  <= activeTP3   : false)
 
// Latch TP flags — one-shot per trade
bool tp1FirstTouch = tp1Hit and not tp1Reached and activeDir != 0
bool tp2FirstTouch = tp2Hit and not tp2Reached and activeDir != 0
bool tp3FirstTouch = tp3Hit and not tp3Reached and activeDir != 0
 
if tp1FirstTouch
    tp1Reached := true
if tp2FirstTouch
    tp2Reached := true
if tp3FirstTouch
    tp3Reached := true
 
// ── Break-even trail ──
// On TP1 first touch (without simultaneous SL hit), move SL to entry.
// From the next bar onward, any wick touching entry = stop-out at break-even.
bool beJustActivated = false
if useBreakEvenInput and tp1FirstTouch and not slHit and activeDir != 0 and not beActive
    activeSL := activeEntry
    beActive := true
    beJustActivated := true
 
// Auto-reset on SL hit or TP3 hit
if (slHit or tp3Hit) and activeDir != 0
    activeDir          := 0
    activeSL           := na
    activeTP1          := na
    activeTP2          := na
    activeTP3          := na
    activeEntry        := na
    activePoolStrength := na
    tp1Reached         := false
    tp2Reached         := false
    tp3Reached         := false
    beActive           := false
 
// ══════════════════════════════════════════════════════════════════════════
// 20. SL/TP LINES & LABELS — AFT-style Pro design
// ══════════════════════════════════════════════════════════════════════════
// Style guide (inspired by AFT v1.5.4):
//   • Entry: dotted blue, width 1
//   • SL:    solid red, width 1 (dimmed when BE is active)
//   • TP1:   dashed green, width 1 (→ solid cyan + ✓ on hit)
//   • TP2:   dashed green, width 1 (→ solid cyan + ✓ on hit)
//   • TP3:   dashed green, width 1 (→ solid cyan + ✓ on hit)
//   • Labels: tinted backgrounds matching line color + adaptive text
//   • % distance from entry shown on each label (toggleable)
 
// Resolve label size from input
labelSize = sizeFromString(labelFontSizeInput)
 
// Layout constants
int LINE_LOOKAHEAD = 30   // initial line projection in bars
int LINE_EXTEND    = 5    // continuous extension while active
int LABEL_OFFSET   = 31   // label x-offset from current bar
 
var line  slLine    = na
var line  tp1Line   = na
var line  tp2Line   = na
var line  tp3Line   = na
var line  entryLine = na
var label slLabel    = na
var label tp1Label   = na
var label tp2Label   = na
var label tp3Label   = na
var label entryLabel = na
var bool  riskLinesActive = false
 
if (confirmedBuy or confirmedSell) and showSlTpInput
    line.delete(slLine),     line.delete(tp1Line),  line.delete(tp2Line)
    line.delete(tp3Line),    line.delete(entryLine)
    label.delete(slLabel),   label.delete(tp1Label), label.delete(tp2Label)
    label.delete(tp3Label),  label.delete(entryLabel)
 
    int startBar = bar_index
 
    // Pro-style line hierarchy: entry dotted, SL solid (1px), TPs dashed (1px)
    entryLine := line.new(startBar, activeEntry, startBar + LINE_LOOKAHEAD, activeEntry,
         color = ENTRY_COLOR, style = line.style_dotted, width = 1)
    slLine    := line.new(startBar, activeSL,    startBar + LINE_LOOKAHEAD, activeSL,
         color = SL_COLOR,    style = line.style_solid,  width = 1)
    tp1Line   := line.new(startBar, activeTP1,   startBar + LINE_LOOKAHEAD, activeTP1,
         color = TP1_COLOR,   style = line.style_dashed, width = 1)
    tp2Line   := line.new(startBar, activeTP2,   startBar + LINE_LOOKAHEAD, activeTP2,
         color = TP2_COLOR,   style = line.style_dashed, width = 1)
    tp3Line   := line.new(startBar, activeTP3,   startBar + LINE_LOOKAHEAD, activeTP3,
         color = TP3_COLOR,   style = line.style_dashed, width = 1)
 
    if showSlTpLabelsInput
        // AFT-style: tinted background label matching line color, white text
        // % from entry shown when showPctOnLabelsInput is ON
        string entryStr = str.tostring(activeEntry, format.mintick)
        string slStr    = str.tostring(activeSL,    format.mintick) + formatPctFromEntry(activeSL,  activeEntry)
        string tp1Str   = str.tostring(activeTP1,   format.mintick) + formatPctFromEntry(activeTP1, activeEntry)
        string tp2Str   = str.tostring(activeTP2,   format.mintick) + formatPctFromEntry(activeTP2, activeEntry)
        string tp3Str   = str.tostring(activeTP3,   format.mintick) + formatPctFromEntry(activeTP3, activeEntry)
 
        entryLabel := label.new(startBar + LABEL_OFFSET, activeEntry, "ENTRY " + entryStr,
             color = ENTRY_COLOR, textcolor = LABEL_TEXT,
             style = label.style_label_left, size = labelSize)
        slLabel    := label.new(startBar + LABEL_OFFSET, activeSL,    "SL "  + slStr,
             color = SL_COLOR, textcolor = LABEL_TEXT,
             style = label.style_label_left, size = labelSize)
        tp1Label   := label.new(startBar + LABEL_OFFSET, activeTP1,   "TP1 " + tp1Str,
             color = TP1_COLOR, textcolor = LABEL_TEXT,
             style = label.style_label_left, size = labelSize)
        tp2Label   := label.new(startBar + LABEL_OFFSET, activeTP2,   "TP2 " + tp2Str,
             color = TP2_COLOR, textcolor = LABEL_TEXT,
             style = label.style_label_left, size = labelSize)
        tp3Label   := label.new(startBar + LABEL_OFFSET, activeTP3,   "TP3 " + tp3Str,
             color = TP3_COLOR, textcolor = LABEL_TEXT,
             style = label.style_label_left, size = labelSize)
 
    riskLinesActive := true
 
// ── TP hit visuals: line turns solid Pro-cyan, label gets ✓ ──
if showSlTpInput and not na(tp1Line) and tp1Reached
    line.set_color(tp1Line, TP_HIT_COLOR)
    line.set_style(tp1Line, line.style_solid)
    if showSlTpLabelsInput and not na(tp1Label)
        label.set_text(tp1Label, "TP1 ✓ " + str.tostring(activeTP1, format.mintick) + formatPctFromEntry(activeTP1, activeEntry))
        label.set_color(tp1Label, TP_HIT_COLOR)
 
if showSlTpInput and not na(tp2Line) and tp2Reached
    line.set_color(tp2Line, TP_HIT_COLOR)
    line.set_style(tp2Line, line.style_solid)
    if showSlTpLabelsInput and not na(tp2Label)
        label.set_text(tp2Label, "TP2 ✓ " + str.tostring(activeTP2, format.mintick) + formatPctFromEntry(activeTP2, activeEntry))
        label.set_color(tp2Label, TP_HIT_COLOR)
 
if showSlTpInput and not na(tp3Line) and tp3Reached
    line.set_color(tp3Line, TP_HIT_COLOR)
    line.set_style(tp3Line, line.style_solid)
    if showSlTpLabelsInput and not na(tp3Label)
        label.set_text(tp3Label, "TP3 ✓ " + str.tostring(activeTP3, format.mintick) + formatPctFromEntry(activeTP3, activeEntry))
        label.set_color(tp3Label, TP_HIT_COLOR)
 
// ── Break-even visualization ──
// When BE is active, original SL line is dimmed (still visible as reference,
// but no longer the "live" stop). The ENTRY label gets a "→ SL (BE)" suffix
// in amber, announcing where the effective stop now sits.
//
// Note: activeSL is logically moved to entry by the BE block above.
// Hit detection uses effectiveSL (captured before BE update), so SL hits
// resolve against the original SL on the bar of the move, then against
// entry on subsequent bars. The visible SL line and effective SL diverge
// here by design — the ENTRY label is the source of truth.
if showSlTpInput and beActive and activeDir != 0
    // Dim the original SL line — it stays visible as a reference but
    // is no longer the active stop.
    if not na(slLine)
        line.set_color(slLine, color.new(SL_COLOR, 70))
    if showSlTpLabelsInput
        if not na(slLabel)
            label.set_color(slLabel, color.new(SL_COLOR, 70))
        if not na(entryLabel)
            string entryStr = str.tostring(activeEntry, format.mintick)
            label.set_text(entryLabel, "ENTRY " + entryStr + "  → SL (BE)")
            label.set_color(entryLabel, BE_COLOR)
 
// Auto-reset visual lines once when position closes
if activeDir == 0 and riskLinesActive and showSlTpInput
    line.delete(slLine),     line.delete(tp1Line),  line.delete(tp2Line)
    line.delete(tp3Line),    line.delete(entryLine)
    label.delete(slLabel),   label.delete(tp1Label), label.delete(tp2Label)
    label.delete(tp3Label),  label.delete(entryLabel)
    riskLinesActive := false
 
// Extend lines to current bar while active
if activeDir != 0 and riskLinesActive and showSlTpInput
    int newEnd = bar_index + LINE_EXTEND
    if not na(slLine)
        line.set_x2(slLine,    newEnd)
        line.set_x2(tp1Line,   newEnd)
        line.set_x2(tp2Line,   newEnd)
        line.set_x2(tp3Line,   newEnd)
        line.set_x2(entryLine, newEnd)
    if showSlTpLabelsInput and not na(slLabel)
        int lblX = newEnd + 1
        label.set_x(slLabel,    lblX)
        label.set_x(tp1Label,   lblX)
        label.set_x(tp2Label,   lblX)
        label.set_x(tp3Label,   lblX)
        label.set_x(entryLabel, lblX)
 
// ══════════════════════════════════════════════════════════════════════════
// 21. DASHBOARD
// ══════════════════════════════════════════════════════════════════════════
// Aggregates: counts of pools by side and state
int activeBullPools = 0
int activeBearPools = 0
float strongestBull = 0.0
float strongestBear = 0.0
float strongestBullLevel = na
float strongestBearLevel = na
float nearestBullDist = 1e9
float nearestBearDist = 1e9
float nearestBullLevel = na
float nearestBearLevel = na
 
if showDashInput and barstate.islast and pools.size() > 0
    for i = 0 to pools.size() - 1
        LiquidityPool p = pools.get(i)
        if p.state == STATE_ACTIVE
            float s = p.computeStrength()
            if p.isHigh
                activeBearPools := activeBearPools + 1
                if s > strongestBear
                    strongestBear := s
                    strongestBearLevel := p.level
                float d = math.abs(p.level - close)
                if d < nearestBearDist and p.level > close
                    nearestBearDist := d
                    nearestBearLevel := p.level
            else
                activeBullPools := activeBullPools + 1
                if s > strongestBull
                    strongestBull := s
                    strongestBullLevel := p.level
                float d = math.abs(p.level - close)
                if d < nearestBullDist and p.level < close
                    nearestBullDist := d
                    nearestBullLevel := p.level
 
float currentRR  = activeDir != 0 ? effectiveTP1Mult : 0.0
float riskPctVal = activeDir != 0 and not na(activeEntry) and activeEntry != 0 and not na(activeSL) ? math.abs(activeEntry - activeSL) / activeEntry * 100.0 : 0.0
 
if showDashInput and barstate.islast
    var table dashTable = table.new(dashPos, 2, 18, TABLE_BG, TABLE_BORDER, 1, TABLE_BORDER, 1)
 
    // Header
    table.cell(dashTable, 0, 0, "💧 Liquidity Pools Pro", text_color = HEADER_TEXT, bgcolor = HEADER_BG, text_size = size.small, text_halign = text.align_center)
    table.merge_cells(dashTable, 0, 0, 1, 0)
 
    // Section: market state
    table.cell(dashTable, 0, 1, "Symbol",    text_color = TEXT_MUTED, text_size = size.small)
    table.cell(dashTable, 1, 1, syminfo.ticker, text_color = TEXT_COLOR, text_size = size.small)
 
    table.cell(dashTable, 0, 2, "TF",        text_color = TEXT_MUTED, text_size = size.small)
    table.cell(dashTable, 1, 2, timeframe.period, text_color = TEXT_COLOR, text_size = size.small)
 
    table.cell(dashTable, 0, 3, "Pivot Len", text_color = TEXT_MUTED, text_size = size.small, bgcolor = TABLE_ROW_ALT)
    table.cell(dashTable, 1, 3, str.tostring(leftLen) + (adaptivePivotInput ? " (adapt)" : ""), text_color = TEXT_COLOR, text_size = size.small, bgcolor = TABLE_ROW_ALT)
 
    // Section: pool stats
    table.cell(dashTable, 0, 4, "Active Pools",   text_color = TEXT_MUTED, text_size = size.small)
    table.cell(dashTable, 1, 4, str.tostring(activeBullPools + activeBearPools) + " (↑" + str.tostring(activeBullPools) + " ↓" + str.tostring(activeBearPools) + ")", text_color = TEXT_COLOR, text_size = size.small)
 
    table.cell(dashTable, 0, 5, "Strong SSL ↑",   text_color = TEXT_MUTED, text_size = size.small, bgcolor = TABLE_ROW_ALT)
    string ssrStr = na(strongestBearLevel) ? "—" : str.tostring(strongestBearLevel, format.mintick) + " [" + str.tostring(math.round(strongestBear)) + "]"
    table.cell(dashTable, 1, 5, ssrStr, text_color = BEAR_TEXT, text_size = size.small, bgcolor = TABLE_ROW_ALT)
 
    table.cell(dashTable, 0, 6, "Strong BSL ↓",   text_color = TEXT_MUTED, text_size = size.small)
    string bslStr = na(strongestBullLevel) ? "—" : str.tostring(strongestBullLevel, format.mintick) + " [" + str.tostring(math.round(strongestBull)) + "]"
    table.cell(dashTable, 1, 6, bslStr, text_color = BULL_TEXT, text_size = size.small)
 
    table.cell(dashTable, 0, 7, "Nearest Above",  text_color = TEXT_MUTED, text_size = size.small, bgcolor = TABLE_ROW_ALT)
    string nearAbove = na(nearestBearLevel) ? "—" : str.tostring(nearestBearLevel, format.mintick)
    table.cell(dashTable, 1, 7, nearAbove, text_color = BEAR_TEXT, text_size = size.small, bgcolor = TABLE_ROW_ALT)
 
    table.cell(dashTable, 0, 8, "Nearest Below",  text_color = TEXT_MUTED, text_size = size.small)
    string nearBelow = na(nearestBullLevel) ? "—" : str.tostring(nearestBullLevel, format.mintick)
    table.cell(dashTable, 1, 8, nearBelow, text_color = BULL_TEXT, text_size = size.small)
 
    // Diagnostics row — helps user understand if/why signals aren't firing
    string diagStr = "Mit:" + str.tostring(totalMitigationsCount) + " Sig:" + str.tostring(signalsFiredCount) + "/" + str.tostring(totalPoolsEverCount)
    color diagClr = signalsFiredCount > 0 ? BULL_TEXT : NEUTRAL_TEXT
    table.cell(dashTable, 0, 9, "Stats",  text_color = TEXT_MUTED, text_size = size.small)
    table.cell(dashTable, 1, 9, diagStr,  text_color = diagClr,    text_size = size.small)
 
    // Section: risk management
    string posStr   = activeDir == 1 ? "🟢 LONG" : activeDir == -1 ? "🔴 SHORT" : "FLAT"
    color  posClr   = activeDir == 1 ? BULL_TEXT : activeDir == -1 ? BEAR_TEXT : TEXT_MUTED
    table.cell(dashTable, 0, 10, "Position", text_color = TEXT_MUTED, text_size = size.small, bgcolor = TABLE_ROW_ALT)
    table.cell(dashTable, 1, 10, posStr,     text_color = posClr,     text_size = size.small, bgcolor = TABLE_ROW_ALT)
 
    // SL row: when BE is active, prefix with "BE @ " to clearly indicate
    // the stop has moved to entry — the value shown is now activeEntry.
    string slStrD  = activeDir != 0 and not na(activeSL)  ? (beActive ? "BE @ " + str.tostring(activeSL, format.mintick) : str.tostring(activeSL, format.mintick)) : "—"
    // TP rows: prefix with ✓ on first touch for visual confirmation
    string tp1StrD = activeDir != 0 and not na(activeTP1) ? (tp1Reached ? "✓ " : "") + str.tostring(activeTP1, format.mintick) : "—"
    string tp2StrD = activeDir != 0 and not na(activeTP2) ? (tp2Reached ? "✓ " : "") + str.tostring(activeTP2, format.mintick) : "—"
    string tp3StrD = activeDir != 0 and not na(activeTP3) ? (tp3Reached ? "✓ " : "") + str.tostring(activeTP3, format.mintick) : "—"
    string rrStrD  = activeDir != 0 ? str.tostring(currentRR, "#.#") + "R" : "—"
    string riskStrD = activeDir != 0 ? str.tostring(riskPctVal, "#.##") + "%" : "—"
 
    // Note: currentRR shows TP1 multiplier (planned R:R), not realized R:R
    color rrClr    = currentRR >= 2.0 ? BULL_TEXT : currentRR >= 1.0 ? NEUTRAL_TEXT : BEAR_TEXT
    color riskClr  = riskPctVal > 3.0 ? BEAR_TEXT  : riskPctVal > 1.0 ? NEUTRAL_TEXT : BULL_TEXT
    // SL color reflects state: amber when BE active, otherwise red
    color slDashColor = beActive ? BE_COLOR : SL_COLOR
    // TP colors brighten to TP_HIT_COLOR after reach
    color tp1DashColor = tp1Reached ? TP_HIT_COLOR : TP1_COLOR
    color tp2DashColor = tp2Reached ? TP_HIT_COLOR : TP2_COLOR
    color tp3DashColor = tp3Reached ? TP_HIT_COLOR : TP3_COLOR
 
    table.cell(dashTable, 0, 11, "SL",      text_color = TEXT_MUTED,  text_size = size.small)
    table.cell(dashTable, 1, 11, slStrD,    text_color = slDashColor, text_size = size.small)
    table.cell(dashTable, 0, 12, "TP1",     text_color = TEXT_MUTED,  text_size = size.small, bgcolor = TABLE_ROW_ALT)
    table.cell(dashTable, 1, 12, tp1StrD,   text_color = tp1DashColor, text_size = size.small, bgcolor = TABLE_ROW_ALT)
    table.cell(dashTable, 0, 13, "TP2",     text_color = TEXT_MUTED,  text_size = size.small)
    table.cell(dashTable, 1, 13, tp2StrD,   text_color = tp2DashColor, text_size = size.small)
    table.cell(dashTable, 0, 14, "TP3",     text_color = TEXT_MUTED,  text_size = size.small, bgcolor = TABLE_ROW_ALT)
    table.cell(dashTable, 1, 14, tp3StrD,   text_color = tp3DashColor, text_size = size.small, bgcolor = TABLE_ROW_ALT)
    table.cell(dashTable, 0, 15, "Plan R:R", text_color = TEXT_MUTED, text_size = size.small)
    table.cell(dashTable, 1, 15, rrStrD,    text_color = rrClr,      text_size = size.small)
    table.cell(dashTable, 0, 16, "Risk %",  text_color = TEXT_MUTED, text_size = size.small, bgcolor = TABLE_ROW_ALT)
    table.cell(dashTable, 1, 16, riskStrD,  text_color = riskClr,    text_size = size.small, bgcolor = TABLE_ROW_ALT)
 
    table.cell(dashTable, 0, 17, "Version " + INDICATOR_VERSION, text_color = TEXT_MUTED, text_size = size.tiny, text_halign = text.align_center)
    table.merge_cells(dashTable, 0, 17, 1, 17)
 
// ══════════════════════════════════════════════════════════════════════════
// 22. WATERMARK
// ══════════════════════════════════════════════════════════════════════════
if barstate.islast and showWatermarkInput
    var table wmTable = table.new(position.bottom_center, 1, 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))
 
// ══════════════════════════════════════════════════════════════════════════
// 23. ALERTS
// ══════════════════════════════════════════════════════════════════════════
string alertPrice  = str.tostring(close, format.mintick)
string alertSLStr  = str.tostring(nz(activeSL),  format.mintick)
string alertTP1Str = str.tostring(nz(activeTP1), format.mintick)
string alertTP2Str = str.tostring(nz(activeTP2), format.mintick)
string alertTP3Str = str.tostring(nz(activeTP3), format.mintick)
string alertEntryStr = str.tostring(nz(activeEntry), format.mintick)
string alertRRStr  = str.tostring(effectiveTP1Mult, "#.#")
string alertStrStr = str.tostring(nz(activePoolStrength), "#")
 
string jsonBuy  = '{"action":"buy","ticker":"' + syminfo.tickerid + '","tf":"' + timeframe.period + '","price":' + alertPrice + ',"sl":' + alertSLStr + ',"tp1":' + alertTP1Str + ',"tp2":' + alertTP2Str + ',"tp3":' + alertTP3Str + ',"rr":' + alertRRStr + ',"pool_strength":' + alertStrStr + '}'
string jsonSell = '{"action":"sell","ticker":"' + syminfo.tickerid + '","tf":"' + timeframe.period + '","price":' + alertPrice + ',"sl":' + alertSLStr + ',"tp1":' + alertTP1Str + ',"tp2":' + alertTP2Str + ',"tp3":' + alertTP3Str + ',"rr":' + alertRRStr + ',"pool_strength":' + alertStrStr + '}'
 
string textBuy  = "🟢 SWEEP BUY | "  + syminfo.tickerid + " | TF: " + timeframe.period + " | Price: " + alertPrice + " | Pool: " + alertStrStr + "/100 | SL: " + alertSLStr + " | TP1: " + alertTP1Str + " | TP2: " + alertTP2Str + " | TP3: " + alertTP3Str + " | R:R: " + alertRRStr
string textSell = "🔴 SWEEP SELL | " + syminfo.tickerid + " | TF: " + timeframe.period + " | Price: " + alertPrice + " | Pool: " + alertStrStr + "/100 | SL: " + alertSLStr + " | TP1: " + alertTP1Str + " | TP2: " + alertTP2Str + " | TP3: " + alertTP3Str + " | R:R: " + alertRRStr
 
if confirmedBuy
    alert(webhookInput ? jsonBuy : textBuy, alert.freq_once_per_bar_close)
 
if confirmedSell
    alert(webhookInput ? jsonSell : textSell, alert.freq_once_per_bar_close)
 
// SL hit
if slHit and alertSlHitInput and barstate.isconfirmed and activeDir != 0
    string slHitMsg = (beActive ? "🛡️ BE STOP-OUT | " : "🛑 SL HIT | ") + syminfo.tickerid + " | Entry: " + alertEntryStr + " | SL: " + alertSLStr
    alert(slHitMsg, alert.freq_once_per_bar_close)
 
// TP & BE alerts (use latched first-touch flags to fire only once per trade)
// Edge case: when TP1 + SL hit on the same bar, the trade is a loss — don't
// fire a misleading "TP1 HIT" notification. Same for TP2/TP3.
if tp1FirstTouch and not slHit and alertTpHitInput and barstate.isconfirmed
    alert("🎯 TP1 HIT | " + syminfo.tickerid + " | TP1: " + alertTP1Str, alert.freq_once_per_bar_close)
 
if beJustActivated and alertTpHitInput and barstate.isconfirmed
    alert("🛡️ BREAK-EVEN | " + syminfo.tickerid + " | SL moved to " + alertEntryStr, alert.freq_once_per_bar_close)
 
if tp2FirstTouch and not slHit and alertTpHitInput and barstate.isconfirmed
    alert("🎯 TP2 HIT | " + syminfo.tickerid + " | TP2: " + alertTP2Str, alert.freq_once_per_bar_close)
 
if tp3FirstTouch and not slHit and alertTpHitInput and barstate.isconfirmed
    alert("🏆 TP3 HIT | " + syminfo.tickerid + " | TP3: " + alertTP3Str, alert.freq_once_per_bar_close)
 
// ══════════════════════════════════════════════════════════════════════════
// END
// ══════════════════════════════════════════════════════════════════════════

← 返回列表