Sweep Desk v2 · Free & open-source

Price breaks a level. What happens next?

Follow the sweep and qualifying inversion close. Read the accepted entry, structural stop and selected target.

Draws on your price chart. The indicator does not place orders.

Pastel clay illustration of a watched price level, a sweep and return, then confirmation and an entry zone with stop and target boundaries.
Workflow illustration
  1. Mark the pools

    Prior highs and lows worth watching.

  2. Confirm the sweep

    Price crosses, returns, then confirms a shift.

  3. Map entry and exits

    An entry zone, invalidation and target levels.

See the real chart

What is Sweep Desk?

Sweep Desk is a free TradingView indicator that follows a price sweep through a reference level, then checks for a qualifying gap inversion or structure shift. Its dashboard explains the waiting state and accepted reference plan, including the structural stop and target. A separate Strategy Lab script tests execution assumptions.

Does every liquidity sweep create an entry?

No. A qualifying sweep starts a sequence; confirmation, context and entry checks must also pass. The default confirmation is a close through an opposing fair value gap. A reference level being crossed alone does not accept a plan. Read the sweep-to-entry sequence

Why does Sweep Desk wait for higher-timeframe data?

The default setup requires completed higher-timeframe gap context and an eligible first touch. Its sixty-minute source must exceed the chart timeframe. Check the New setups data row and Co-Pilot for the prerequisite that is still missing. Read the dashboard prerequisites

Must price return to the inverted gap before entry?

Not in the default Confirmation close mode, which accepts a qualifying confirmation close. Optional modes wait for a later close inside the zone or an edge or midpoint limit. Their arming candle cannot satisfy the later-entry requirement. Compare the entry modes

From a marked level to clear rules.

Built from a trader’s question: how do marked levels become entry and exit rules?

Inspired by iFVG Ultimate+ by TakingProphets / DodgysDD. These are original public-concept rules; private grades, LRL weights and discretionary refinements are not reproduced.

Illustrative price chart3 closed candles shown
Start with an earlier low.The tool marks 64 from earlier price history. It is a level to watch, not proof of orders waiting there. Green candles close above their open; red candles close below. Wicks show high and low. The earlier reference low is 64. The gap is not highlighted at this step. Watching the level.
Gold line = earlier low · White dot = latest close
Watching the level
1 / 5 · The level

Start with an earlier low.

The tool marks 64 from earlier price history. It is a level to watch, not proof of orders waiting there.

The entry rules are not complete yet.

Fictional prices and supplied history. Defaults also require a first touch of a fresh area from a higher timeframe. See the extra lesson below.

How could this help a trade?

Repeatable rules define qualification, entry and exit. Results depend on price, size and costs. A valid entry can lose.

Which close accepts the plan?

Compare the default confirmation-close entry with an optional return inside.

Illustrative price chart1 closed candles shown
An earlier low is already marked.The reference at 64 comes from earlier history. Only one new candle is shown so far. Green candles close above their open; red candles close below. Wicks show high and low. The earlier reference low is 64. The gap is not highlighted at this step. Watching the level.
Gold line = earlier low · White dot = latest close
Watching the level
Candle 1 / 6

An earlier low is already marked.

The reference at 64 comes from earlier history. Only one new candle is shown so far.

Entry methods only. The required higher-timeframe check is switched off here; explore it below.

What is supplied in this example?

Fictional prices; previously confirmed low 64 and high 160; ATR 10. Single-gap long-side inversion; Confirmation close or the labelled Close inside alternative. HTF delivery, SMT, momentum, vote minimum, volume and time gates are disabled to isolate entry timing. These six candles do not calculate earlier pools or ATR. The simulator below recalculates ATR from longer fictional history.

Why might it still wait? How do risk and exits work?
One-hour area → five-minute check0 later candles shown
Start with the larger chart.A completed bullish one-hour gap, 80–90, is supplied. Wait for the first later five-minute candle close. Supplied completed one-hour gap: 80 to 90. Green candles rise, red candles fall. This diagram checks context only, without an entry plan.
Completed bullish 1h gap: 80–90. The 5m candles start afterward. Gold ring = first touch.
First-touch age
Required check
Waiting
Entry signal
None here

Start with the larger chart.

A completed bullish one-hour gap, 80–90, is supplied. Wait for the first later five-minute candle close.

Supplied history, aligned time boundaries and any accepted-setup event. Only first-touch context is simulated; other entry checks remain separate.

Does a gap mean nobody traded there?

No. It compares the first and third wicks. The middle candle can trade through the strip. It does not record missing transactions.

Find the same steps on your chart.

Read state → zone → plan.

Actual Sweep Desk v2.0 on OANDA gold 5m, scanning with data prerequisites and the Co-Pilot visible; no active entry.
Gold 5m · Single mode · Clean view · scanning, with no active entryOpen full-size chart ↗
  1. 1 The state row

    SCANNING watches levels. SWEPT waits for confirmation. ARMED waits for entry. FILLED tracks the paper trade.

  2. 2 The entry zone

    Confirmation identifies this fixed area. Default entry uses that confirming close; return modes explicitly wait.

  3. 3 The plan

    Read entry, stop and selected final target together. The stop can move after milestones.

Explore the full price simulator

Edit the sweep, confirmation, later close and entry mode. This long-side example calculates ATR from fictional history, with supplied levels and context gates off.

CHANGE THE CANDLE. SEE WHY.

Which close begins the plan?

Compare the default confirmation-close entry with an optional return inside.

Earlier candles → test candleTest close 86
Resting low64Paper stop54145119.7594.569.2544
Inverted gapDrag the blue close ↕

Closed candles. The last is a later observation.

Watch the same candle change
86
94
How the calculation works & more controls
55
0

Raid: wick-through ≥ 0.05 ATR, close strictly above pool. Inversion: close strictly above gap, push ≥ one ATR. Later fills include both edges.

Confirmation: twelve bars allowed. Close-entry outcomes begin on later bars. Close inside is an alternative, with thirty armed bars and fill checked before expiry. Its entry recalculates risk from that acceptance close; accepted geometry stays fixed.

Open plan: entry 94.00, stop 54.00, targets 134.00 / 174.00 / 214.00. Opposing liquidity: none qualifies.

Illustrative inputs · changing a slider recalculates this example · no market feed

Paste it into TradingView.

Both complete Pine v6 scripts are free, under MIT.

  1. 1

    Open Pine Editor

    Create a new indicator on your chart.

  2. 2

    Paste the code

    Copy below and replace the sample code.

  3. 3

    Add to chart

    Save it. Keep defaults and choose an HTF FVG source above your chart.

The indicator

Levels, entry plans, dashboard, Co-Pilot and alerts.

Indicator code

Copy the code as it is. No coding needed.

View the full code
// ═══════════════════════════════════════════════════════════════════════════════
//  SWEEP DESK — Liquidity · Shift · FVG  ·  Pine Script v6  ·  v2.0.0
//  The liquidity-sweep model made mechanical and honest, with closed-bar structural references and explicit paper execution assumptions.
//
//  THE SEQUENCE (every step commits on the CLOSED bar):
//    1 · POOLS   resting liquidity = confirmed swing highs/lows, equal highs/lows, killzone-session
//                highs/lows, previous day / week highs/lows. Each pool is resting until it is swept.
//    2 · SWEEP   a bar wicks THROUGH a pool by ≥ X ATR and CLOSES back inside → the pool is raided.
//    3 · CONFIRM within N bars, either (a) iFVG — a bar CLOSES THROUGH an opposing imbalance printed near the
//                raid, so that gap INVERTS and becomes the entry zone (an original, explicitly defined inversion model), or (b) MSS — a
//                close beyond the last opposing short-term swing with displacement (leg from the extreme ≥ k ATR).
//    4 · ZONE    iFVG: the inverted gap · MSS: the 3-candle imbalance printed inside the shift leg.
//    5 · ENTRY   confirmation close (v2 default); optional later close inside or resting edge / midpoint limit.
//    6 · STOP    the sweep extreme ± a buffer — the structural invalidation, not a guess.
//    7 · TARGET  frozen 1R / 2R / 3R milestones; optional nearest qualifying fresh opposing pool; explicit TP3 fallback.
//
//  WHY IT EXISTS  "Finding the liquidity is easy; the entries and exits are where the gold is." This
//                 build answers that with a state machine you can read on the dashboard, stop-first
//                 accounting, and a track record that counts every setup it ever armed.
//  STATUS         v2.0.0 · reliability revision · verification scope in docs/build-report.md · paper
//                 ledger has NO costs · the Strategy Lab measures independent broker fills with costs · not a validated edge.
//
//  License: MIT — share freely, keep this header.  © 2026 ProEA Lab · built for r/pinescript
// ═══════════════════════════════════════════════════════════════════════════════
//@version=6
indicator("Sweep Desk — Liquidity · Shift · FVG", "SweepDesk", overlay = true, behind_chart = false, max_bars_back = 3000, max_lines_count = 250, max_labels_count = 200, max_boxes_count = 120)

// ─────────────────────────────────────────────────────────────────── INPUTS · liquidity pools
gP = "Liquidity pools"
inPivLen   = input.int(5, "Swing pivot length", minval = 2, maxval = 30, group = gP,
     tooltip = "Bars on each side that confirm a swing high / low. A swing becomes a resting pool N bars after it forms — no repainting, some lag.", display = display.none)
inEqTol    = input.float(0.15, "Equal highs / lows tolerance (× ATR)", minval = 0.0, maxval = 1.0, step = 0.05, group = gP,
     tooltip = "A new swing within this distance of a resting pool merges into it as an EQUAL high / low (EQH / EQL) — the pools the market loves to raid.", display = display.none)
inPoolSes  = input.bool(true, "Killzone session highs / lows", group = gP, tooltip = "When a killzone ends, its high and low become resting pools.", display = display.none)
inPoolPD   = input.bool(true, "Previous day high / low", group = gP, display = display.none)
inPoolPW   = input.bool(true, "Previous week high / low", group = gP, display = display.none)
inMaxPools = input.int(10, "Max resting pools per side", minval = 3, maxval = 30, group = gP, tooltip = "Oldest swing pools are dropped beyond this (day / week pools are protected; session pools follow the age limit).", display = display.none)
inPoolAge  = input.int(400, "Drop a resting swing / session pool after N bars", minval = 50, maxval = 3000, group = gP, display = display.none)
inHtfOn    = input.bool(true, "Higher-timeframe swing pools", group = gP, tooltip = "Confirmed swing highs / lows from a higher timeframe rest as pools too (kind HTF). Read from the last CLOSED higher-timeframe bar — no lookahead.", display = display.none)
inHtf      = input.timeframe("60", "  Higher timeframe", group = gP, display = display.none)
inHtfLen   = input.int(5, "  HTF pivot length", minval = 2, maxval = 20, group = gP, display = display.none)
inPoolNear = input.float(8.0, "Draw pools within N ATR of price", minval = 1.0, maxval = 50.0, step = 0.5, group = gP, tooltip = "Display only — every pool is still tracked and can be a target. Keeps the chart focused on the liquidity that matters now.", display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · sweep filters
gS = "Sweep filters (better sweeps, fewer fakes)"
inSweepMin  = input.float(0.05, "Wick beyond the pool ≥ ATR ×", minval = 0.0, maxval = 2.0, step = 0.05, group = gS,
     tooltip = "A touch is not a sweep. The wick must go through the pool by at least this much ATR and the bar must CLOSE back on the pool's side.", display = display.none)
inSweepVol  = input.bool(false, "Require a volume spike on the sweep bar", group = gS, display = display.none)
inSweepVolK = input.float(1.3, "Volume ≥ SMA(20) ×", minval = 1.0, maxval = 5.0, step = 0.1, group = gS, display = display.none)
inSweepKz   = input.bool(false, "Only sweeps inside a killzone", group = gS, tooltip = "Off = any time. On = the sweep bar must fall inside one of the killzones below.", display = display.none)
inPoolMin   = input.string("Any pool", "A setup needs a sweep of", options = ["Any pool", "Strong pools only"], group = gS,
     tooltip = "Strong pools = equal highs/lows, killzone session extremes, higher-timeframe swings, previous day / week levels. Plain swing pools are still tracked and drawn, but sweeping one does not start a setup.", display = display.none)
inDir       = input.string("Both", "Direction", options = ["Both", "Long only", "Short only"], group = gS, display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · shift + FVG
gM = "Confirmation (iFVG / MSS) + zone"
inConfirm   = input.string("FVG inversion (iFVG)", "Confirmation", options = ["FVG inversion (iFVG)", "Structure shift (MSS)", "Either"], group = gM,
     tooltip = "How the reversal is confirmed after the raid. iFVG = a bar CLOSES through an opposing imbalance printed near the raid — that gap inverts and becomes the entry zone (the inversion model). MSS = a close beyond the last short-term swing with displacement, then the imbalance in that leg. Either = whichever prints first.", display = display.none)
inIfvgBack  = input.int(40, "iFVG: the gap may have formed up to N bars before the sweep", minval = 5, maxval = 200, group = gM, display = display.none)
inShiftLeg  = input.int(3, "Short-term swing length (the swing the shift must break)", minval = 2, maxval = 10, group = gM, display = display.none)
inShiftWin  = input.int(12, "Confirmation must happen within N bars of the sweep", minval = 3, maxval = 60, group = gM, display = display.none)
inDispAtr   = input.float(1.0, "Displacement: leg from the sweep extreme ≥ ATR ×", minval = 0.2, maxval = 5.0, step = 0.1, group = gM,
     tooltip = "The confirming bar's close must be at least this far (in ATR) from the sweep extreme — a real push, not a drift. Applies to both models.", display = display.none)
inFvgMin    = input.float(0.10, "FVG size ≥ ATR ×", minval = 0.0, maxval = 2.0, step = 0.05, group = gM, display = display.none)
inFvgPick   = input.string("Nearest", "Which FVG in the leg", options = ["Nearest", "Deepest"], group = gM,
     tooltip = "Nearest = the last imbalance printed in the shift leg (price reaches it first). Deepest = the first one (bigger discount, fills less often).", display = display.none)
inFvgWin    = input.int(8, "MSS: the FVG must exist within N bars after the shift", minval = 1, maxval = 30, group = gM, display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · entry / stop / targets
gE = "Entry · stop · targets"
inEntryMode = input.string("Confirmation close", "Entry", options = ["Confirmation close", "FVG edge", "CE (50%)", "Close inside"], group = gE,
     tooltip = "Confirmation close = enter at the confirmed inversion / MSS-zone acceptance close. This is the v2 factory choice. Close entries cannot exit on earlier wicks. FVG edge = limit at the near edge of the gap. CE = limit at the gap's midpoint (consequent encroachment). Close inside = wait for a bar to close inside the gap, enter at that close.", display = display.none)
inSlMode    = input.string("Sweep extreme", "Stop anchor", options = ["Auto", "Sweep extreme", "Zone far edge"], group = gE,
     tooltip = "Sweep extreme = the structural invalidation: if price trades back through the raided low / high, the idea is wrong. Zone far edge = the other side of the entry zone — tighter, more stop-outs. Auto = the sweep extreme unless it sits more than N ATR from the entry (a stop that far is no longer a structural stop for this timeframe), then the zone far edge.", display = display.none)
inSlMaxAtr  = input.float(2.5, "Auto: sweep extreme allowed up to N ATR from the entry", minval = 0.5, maxval = 10.0, step = 0.5, group = gE, display = display.none)
inSlBuf     = input.float(0.10, "Stop buffer (× ATR)", minval = 0.0, maxval = 1.0, step = 0.05, group = gE, display = display.none)
inTp1       = input.float(1.0, "TP1 (R)", minval = 0.3, maxval = 10, step = 0.1, group = gE, display = display.none)
inTp2       = input.float(2.0, "TP2 (R)", minval = 0.5, maxval = 20, step = 0.1, group = gE, display = display.none)
inTp3       = input.float(3.0, "TP3 (R)", minval = 0.5, maxval = 30, step = 0.1, group = gE, display = display.none)
inTpLiqMinR = input.float(2.0, "Liquidity target must be ≥ R", minval = 0.3, maxval = 10, step = 0.1, group = gE,
     tooltip = "The opposing-liquidity target is the nearest resting pool on the other side that is at least this many R away.", display = display.none)
inPrimary   = input.string("R ladder (TP3)", "Final target", options = ["Liquidity", "R ladder (TP3)"], group = gE,
     tooltip = "Which target closes the trade. Liquidity = the opposing pool (falls back to TP3 when none qualifies). TP1 / TP2 are milestones either way.", display = display.none)
inBE        = input.bool(true, "TP1 → stop to breakeven", group = gE, display = display.none)
inExpire    = input.int(30, "Unfilled setup expires after N bars", minval = 3, maxval = 300, group = gE, display = display.none)
inMaxTrade  = input.int(200, "Open trade times out after N bars", minval = 10, maxval = 2000, group = gE, display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · killzones
gK = "Killzones (exchange time)"
inTZ  = input.string("America/New_York", "Timezone", group = gK, tooltip = "IANA name. ICT killzones are defined in New York time.", display = display.none)
inKz1 = input.session("0200-0500", "London", group = gK, display = display.none)
inKz2 = input.session("0830-1100", "New York AM", group = gK, display = display.none)
inKz3 = input.session("1330-1600", "New York PM", group = gK, display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · style / panels / alerts
gV = "Style"
inView   = input.string("Clean", "View preset", options = ["Minimal", "Clean", "Pro", "Full"], group = gV,
     tooltip = "Minimal = the active setup only. Clean = + resting pools + setup FVG + MSS. Pro = + every FVG, sweep tags, pool labels. Full = + killzone shading.", display = display.none)
inTheme  = input.string("Aurora Neon", "Theme", options = ["Aurora Neon", "Royal Gold", "Colorblind-safe"], group = gV, display = display.none)
inZones  = input.bool(true, "Gradient risk / reward zones", group = gV, display = display.none)
inTint   = input.bool(true, "Tint candles while a trade is open", group = gV, display = display.none)
inGlow   = input.bool(true, "Glow on strong pools (EQ · session · HTF · day / week)", group = gV, display = display.none)
inExtend = input.int(20, "Pool label offset (bars)", minval = 5, maxval = 200, group = gV, display = display.none)
gD = "Dashboard"
inDash    = input.bool(true, "Show dashboard", group = gD, display = display.none)
inDashPos = input.string("Top Right", "Position", options = ["Top Right", "Top Left", "Bottom Right", "Bottom Left", "Middle Right", "Middle Left"], group = gD, display = display.none)
inSize    = input.string("Medium", "Size", options = ["Small", "Medium", "Large"], group = gD, display = display.none)
inZebra   = input.bool(true, "Row stripes", group = gD, display = display.none)
gN = "Narrative · Co-Pilot"
inNarr     = input.string("Standard", "Narrative depth", options = ["Off", "Brief", "Standard", "Detailed"], group = gN, display = display.none)
inNarrPos  = input.string("Bottom Left", "Position", options = ["Bottom Left", "Bottom Right", "Top Left", "Top Right", "Middle Left", "Middle Right"], group = gN, display = display.none)
inNarrSize = input.string("Auto", "Size", options = ["Auto", "Small", "Medium", "Large"], group = gN, display = display.none)
inNarrWrap = input.int(64, "Line width (characters)", minval = 30, maxval = 120, group = gN, display = display.none)
gA = "Alerts"
inAlertJson = input.bool(true, "alert() payload as JSON (webhooks)", group = gA, tooltip = "Choose 'Any alert() function call' when creating the alert.", display = display.none)

// v2 additions follow all existing controls to preserve prior input identities.
gV2 = "v2 · transparent confirmation / target rules"
inGapMode = input.string("Single", "iFVG mode", options = ["Single", "Series"], group = gV2, display = display.none, tooltip = "Single = individual three-candle gap. Series = a union of same-direction gaps born on consecutive chart bars, before inversion; the whole union must be crossed by a later close. This precise adjacency rule is original, not a claim of private parity.")
inMacro = input.string("Off", "Confirmation macro window", options = ["Off", "45–15", "50–10", "US open 30m"], group = gV2, display = display.none, tooltip = "Evaluated at the confirmation candle CLOSE in the selected timezone. 45–15: minute 45 through 14; 50–10: minute 50 through 09; US open 30m: 09:30 inclusive to 10:00 exclusive. No exchange holiday calendar.")
inTargetQuality = input.string("Any fresh pool", "Opposing target quality", options = ["Any fresh pool", "Clustered (2+ touches)"], group = gV2, display = display.none, tooltip = "Clustered requires two separately confirmed chart swing observations merged into one still-fresh EQ pool. This is a transparent level cluster, not a trendline detector. The minimum-R rule and explicit TP3 fallback still apply.")

// ─────────────────────────────────────────────────────────────────── THEME
type Theme
    color bg       = #0B0E14
    color frame    = #B8923A
    color accent   = #D8B14A
    color accentHi = #E8C25A
    color up       = #2BE7C7
    color down     = #FF3B5C
    color flat     = #6B7280
    color txt      = #D7DCE6
    color txtHi    = #ECEFF5
themeOf(string name) =>
    Theme t = Theme.new()
    if name == "Aurora Neon"
        t.bg       := #0E1118
        t.frame    := #2C3A5A
        t.accent   := #7C9CFF
        t.accentHi := #A9B8FF
        t.up       := #2AF0C8
        t.down     := #FF4D6D
        t.flat     := #5B6472
        t.txt      := #E6E9EF
        t.txtHi    := #F2F4F9
    else if name == "Colorblind-safe"
        t.bg       := #0E1014
        t.frame    := #3A4150
        t.accent   := #56B4E9
        t.accentHi := #8ED0F5
        t.up       := #E69F00
        t.down     := #0072B2
        t.flat     := #8C92A0
        t.txt      := #E8EAED
        t.txtHi    := #FFFFFF
    t
Theme th = themeOf(inTheme)
bool vClean = inView != "Minimal"
bool vPro   = inView == "Pro" or inView == "Full"
bool vFull  = inView == "Full"
fmt(float x) => str.tostring(x, format.mintick)
clamp01(float x) => math.max(0.0, math.min(1.0, x))

// ── PURE RELIABILITY HELPERS BEGIN · shared by literal Pine fixtures and broker adapter
sdSign(float x) =>
    na(x) ? 0 : int(nz(x / math.abs(x), 0))
sdCompare(float lhs, float rhs) =>
    float tolerance = 4 * 2.220446049250313e-16 * math.max(math.abs(lhs), math.abs(rhs))
    sdSign(math.abs(lhs - rhs) - tolerance) <= 0 ? 0 : sdSign(lhs - rhs)
sdBarValid(float o, float h, float l, float c) =>
    not na(o) and not na(h) and not na(l) and not na(c) and sdSign(h - l) >= 0 and sdSign(o - l) >= 0 and sdSign(h - o) >= 0 and sdSign(c - l) >= 0 and sdSign(h - c) >= 0
sdTickOut(float px, int roundingDirection, float tick) =>
    float raw = px / tick
    bool supported = not na(raw) and math.abs(raw) <= 9e18
    float nearest = supported ? math.round(raw) : na
    // Never absorb a genuine fraction of a tick at a large absolute coordinate.
    float tolerance = math.min(1e-5, 8 * 2.220446049250313e-16 * math.max(1, math.abs(raw)))
    float normalized = sdSign(math.abs(raw - nearest) - tolerance) <= 0 ? nearest : raw
    supported ? (roundingDirection == 1 ? math.ceil(normalized) : math.floor(normalized)) * tick : float(na)
sdDistance(float fromPrice, float toPrice, int direction, float tick) =>
    float raw = (toPrice - fromPrice) * direction
    float ticks = raw / tick
    float nearest = math.round(ticks)
    float tolerance = math.min(tick * 1e-5, 8 * 2.220446049250313e-16 * math.max(math.abs(fromPrice), math.abs(toPrice)))
    sdSign(math.abs(raw - nearest * tick) - tolerance) <= 0 ? nearest * tick : raw
sdPoolObservation(int side, float level, float h, float l, float c, float minimumDepth) =>
    bool traversed = side == 1 ? sdCompare(h, level) > 0 : sdCompare(l, level) < 0
    bool raid = traversed and (side == 1 ? sdCompare(h, level + minimumDepth) >= 0 and sdCompare(c, level) < 0 : sdCompare(l, level - minimumDepth) <= 0 and sdCompare(c, level) > 0)
    [traversed, raid]
sdPlanGeometry(int direction, float entry, float anchor, float atrValue, float tick, float buffer, float r1, float r2, float r3) =>
    bool available = not na(entry) and not na(anchor) and not na(atrValue) and sdSign(atrValue) > 0 and sdSign(tick) > 0
    bool ordered = r1 > 0 and sdCompare(r2, r1) > 0 and sdCompare(r3, r2) > 0
    float rawStop = anchor - direction * buffer * atrValue
    float minimumStop = entry - direction * tick
    float sl = sdTickOut(direction == 1 ? math.min(rawStop, minimumStop) : math.max(rawStop, minimumStop), -direction, tick)
    float risk = sdDistance(sl, entry, direction, tick)
    float t1 = sdTickOut(entry + direction * r1 * risk, direction, tick)
    float t2 = sdTickOut(entry + direction * r2 * risk, direction, tick)
    float t3 = sdTickOut(entry + direction * r3 * risk, direction, tick)
    bool valid = available and ordered and sdCompare(risk, tick) >= 0 and sdCompare((t1 - entry) * direction, 0) > 0 and sdCompare((t2 - t1) * direction, 0) > 0 and sdCompare((t3 - t2) * direction, 0) > 0
    string reason = not available ? "Unavailable price / ATR / tick" : not ordered ? "Require 0 < TP1 < TP2 < TP3" : not valid ? "Rounded plan geometry invalid" : "Plan valid"
    [sl, risk, t1, t2, t3, valid, reason]
sdOutcome(int direction, float stop, float target, float o, float h, float l, float c, int entryBar, int currentBar, int maximumHold) =>
    bool ended = false
    float price = na
    string why = ""
    bool ambiguous = false
    if currentBar > entryBar and sdBarValid(o, h, l, c)
        bool stopGap = sdCompare(o, stop) * direction <= 0
        bool targetGap = sdCompare(o, target) * direction >= 0
        bool stopTouch = direction == 1 ? sdCompare(l, stop) <= 0 : sdCompare(h, stop) >= 0
        bool targetTouch = direction == 1 ? sdCompare(h, target) >= 0 : sdCompare(l, target) <= 0
        if stopGap
            price := o
            why := "GAP STOP"
        else if targetGap
            price := target
            why := "TARGET"
        else if stopTouch
            price := stop
            why := "STOP"
            ambiguous := targetTouch
        else if targetTouch
            price := target
            why := "TARGET"
        else if currentBar - entryBar >= maximumHold
            price := c
            why := "TIME"
        ended := not na(price)
    [ended, price, why, ambiguous]
sdSeriesJoin(bool enabled, int direction, int previousDirection, int birth, int previousBirth, bool previousInverted) =>
    enabled and not previousInverted and direction == previousDirection and birth == previousBirth + 1
sdInversion(int direction, float closedPrice, float top, float bottom) =>
    direction == 1 ? sdCompare(closedPrice, bottom) < 0 : sdCompare(closedPrice, top) > 0
sdEscape(string value) =>
    str.replace_all(str.replace_all(str.replace_all(str.replace_all(value, "\\", "\\\\"), "\"", "\\\""), "\n", "\\n"), "\t", "\\t")
sdNumber(float value) =>
    string result = "null"
    if not na(value)
        if sdSign(value) == 0
            result := "0"
        else
            int exponent = int(math.floor(math.log10(math.abs(value))))
            result := exponent < -6 or exponent > 12 ? str.tostring(value / math.pow(10, exponent), "0.################") + "e" + str.tostring(exponent) : str.tostring(value, "0.################")
    result
sdMacroWindow(string mode, int localHour, int localMinute) =>
    mode == "Off" or (mode == "45–15" ? localMinute >= 45 or localMinute < 15 : mode == "50–10" ? localMinute >= 50 or localMinute < 10 : localHour == 9 and localMinute >= 30)
sdMilestones(int direction, float entry, float stop, float tp1, float tp2, float h, float l, bool previousOne, bool previousTwo, bool moveBreakeven, float tick) =>
    bool one = previousOne or (direction == 1 ? sdCompare(h, tp1) >= 0 : sdCompare(l, tp1) <= 0)
    bool two = previousTwo or (direction == 1 ? sdCompare(h, tp2) >= 0 : sdCompare(l, tp2) <= 0)
    float nextStop = stop
    if one and not previousOne and moveBreakeven
        float be = sdTickOut(entry, -direction, tick)
        nextStop := direction == 1 ? math.max(nextStop, be) : math.min(nextStop, be)
    if two and not previousTwo
        nextStop := direction == 1 ? math.max(nextStop, tp1) : math.min(nextStop, tp1)
    [nextStop, one, two, one and not previousOne, two and not previousTwo]
sdQuantity(bool fixedMode, float fixedUnits, float cashBudget, float unitRisk, float unitNotional, float equity, float capPercent, float increment) =>
    bool available = not na(unitRisk) and not na(unitNotional) and not na(equity) and not na(increment) and sdSign(unitRisk) > 0 and sdSign(unitNotional) > 0 and sdSign(equity) > 0 and sdSign(increment) > 0 and sdSign(capPercent) > 0 and (fixedMode ? sdSign(fixedUnits) > 0 : sdSign(cashBudget) > 0)
    float requested = fixedMode ? fixedUnits : cashBudget / unitRisk
    float capUnits = equity * capPercent / 100 / unitNotional
    float steps = available ? math.min(requested, capUnits) / increment : 0
    bool supported = steps <= 9e18
    float nearest = supported ? math.round(steps) : 0
    float tolerance = math.min(1e-5, 4 * 2.220446049250313e-16 * math.max(1, math.abs(steps)))
    float normalized = sdSign(math.abs(steps - nearest) - tolerance) <= 0 ? nearest : steps
    float quantity = available and supported ? math.floor(normalized) * increment : 0
    float actualRisk = quantity * unitRisk
    float notional = quantity * unitNotional
    bool withinBudget = (fixedMode or sdCompare(actualRisk, cashBudget) <= 0) and sdCompare(notional, equity * capPercent / 100) <= 0
    bool valid = available and supported and sdSign(quantity) > 0 and withinBudget
    string reason = not available ? "Price / account conversion / sizing unavailable" : not supported ? "Quantity needs more than supported integer steps" : sdSign(quantity) <= 0 ? "No size fits risk / equity cap at minimum increment" : not withinBudget ? "Sizing arithmetic exceeded budget" : "Sizing valid"
    [valid ? quantity : 0.0, valid ? actualRisk : float(na), valid ? notional : float(na), valid, reason]
sdPendingCloseDecision(int direction, float closedPrice, float zoneTop, float zoneBottom, float frozenStop) =>
    bool inside = sdCompare(closedPrice, zoneBottom) >= 0 and sdCompare(closedPrice, zoneTop) <= 0
    bool invalid = sdCompare(closedPrice, frozenStop) * direction < 0
    [inside, invalid, inside and not invalid]
sdGapValid(float top, float bottom, float minimumSize) =>
    not na(top) and not na(bottom) and not na(minimumSize) and sdCompare(top, bottom) > 0 and sdCompare(top, bottom + minimumSize) >= 0
sdDisplacement(int direction, float closedPrice, float extreme, float minimumDistance) =>
    not na(closedPrice) and not na(extreme) and not na(minimumDistance) and sdCompare(closedPrice, extreme + direction * minimumDistance) * direction >= 0
// ── PURE RELIABILITY HELPERS END

if barstate.isfirst
    if not chart.is_standard or not timeframe.isintraday
        runtime.error("Sweep Desk requires standard time-based intraday candles.")
    if not (inTp1 > 0 and inTp2 > inTp1 and inTp3 > inTp2)
        runtime.error("Targets must satisfy 0 < TP1 < TP2 < TP3.")
if barstate.isconfirmed and not sdBarValid(open, high, low, close)
    runtime.error("Sweep Desk: missing or malformed confirmed OHLC. No reliable price transition can be calculated.")

// ═══════════════════════════════════════════════════════════════════ ENGINE INPUTS (ta.* at global scope)
float atr   = ta.atr(14)
float atrU  = math.max(atr, syminfo.mintick)
float ph    = ta.pivothigh(high, inPivLen, inPivLen)
float pl    = ta.pivotlow(low, inPivLen, inPivLen)
float phS   = ta.pivothigh(high, inShiftLeg, inShiftLeg)
float plS   = ta.pivotlow(low, inShiftLeg, inShiftLeg)
float volMa = ta.sma(nz(volume, 0.0), 20)
bool  volOK = not inSweepVol or (not na(volume) and sdSign(volume) > 0 and not na(volMa) and sdSign(volMa) > 0 and sdCompare(volume, inSweepVolK * volMa) >= 0)
bool  kz1   = not na(time(timeframe.period, inKz1, inTZ))
bool  kz2   = not na(time(timeframe.period, inKz2, inTZ))
bool  kz3   = not na(time(timeframe.period, inKz3, inTZ))
bool  inKz  = kz1 or kz2 or kz3
string kzName = kz1 ? "London" : kz2 ? "NY AM" : kz3 ? "NY PM" : "outside killzones"
// previous day / week highs and lows — the [1] + lookahead_on pair = the last CLOSED period (never the forming one)
[pdStamp, pdh, pdl] = request.security(syminfo.tickerid, "D", [time[1], high[1], low[1]], lookahead = barmerge.lookahead_on)
[pwStamp, pwh, pwl] = request.security(syminfo.tickerid, "W", [time[1], high[1], low[1]], lookahead = barmerge.lookahead_on)
bool  dayOK  = timeframe.in_seconds() < timeframe.in_seconds("D")
bool  weekOK = timeframe.in_seconds() < timeframe.in_seconds("W")
htfPhFn() => ta.pivothigh(high, inHtfLen, inHtfLen)
htfPlFn() => ta.pivotlow(low, inHtfLen, inHtfLen)
float htfPh  = request.security(syminfo.tickerid, inHtf, htfPhFn()[1], lookahead = barmerge.lookahead_on)
float htfPl  = request.security(syminfo.tickerid, inHtf, htfPlFn()[1], lookahead = barmerge.lookahead_on)
bool  htfOK  = timeframe.in_seconds(inHtf) > timeframe.in_seconds()
int   htfMult = math.max(1, math.round(timeframe.in_seconds(inHtf) / timeframe.in_seconds()))

// CONFLUENCE FRAGMENT BEGIN
// Public confluence concepts, original deterministic definitions. No private DD grade or LRL formula.
gCF = "Confluence · transparent rules"
inCfPda = input.bool(true, "Require first-touch HTF FVG delivery", group = gCF, display = display.none,
     tooltip = "A previously untouched, completed higher-timeframe 3-candle gap must be touched first. Its delivery context lasts N chart bars and can admit only one plan. This is our explicit PDA definition, not a private vendor formula.")
inCfHtf = input.timeframe("60", "  HTF FVG source", group = gCF, display = display.none)
inCfPdaAge = input.int(6, "  First-touch context lasts chart bars", minval = 1, maxval = 30, group = gCF, display = display.none)
inCfPdaLife = input.int(50, "  Untouched gap lifetime · HTF periods", minval = 2, maxval = 200, group = gCF, display = display.none)
inCfSmt = input.bool(false, "Require paired-market SMT", group = gCF, display = display.none,
     tooltip = "Select a meaningful comparison yourself. Uses strictly aligned, previously CLOSED chart and comparison bars. A missing bar resets the comparison window. Context is deliberately one chart bar late; no automatic correlation claim.")
inCfPair = input.symbol("BINANCE:BTCUSDT", "  SMT comparison symbol", group = gCF, display = display.none)
inCfInverse = input.bool(false, "  Inverse relationship", group = gCF, display = display.none)
inCfWindow = input.int(10, "  SMT aligned-bar reference window", minval = 3, maxval = 100, group = gCF, display = display.none)
inCfSmtAge = input.int(6, "  SMT context lasts chart bars", minval = 1, maxval = 30, group = gCF, display = display.none)
inCfMomentum = input.bool(false, "Require directional momentum candle", group = gCF, display = display.none)
inCfBody = input.float(0.5, "  Body size ≥ ATR ×", minval = 0.0, maxval = 3.0, step = 0.1, group = gCF, display = display.none)
inCfLocation = input.float(0.7, "  Directional close location ≥", minval = 0.5, maxval = 1.0, step = 0.05, group = gCF, display = display.none)
inCfVotes = input.int(0, "Minimum observed confluences · 0–4", minval = 0, maxval = 4, group = gCF, display = display.none,
     tooltip = "One vote each: available first-touch HTF FVG, enabled aligned SMT, directional momentum, positive volume ≥ 1.3× its 20-bar mean. This is a transparent checklist, not an A+ grade, probability or optimized threshold. SMT supplies no vote while disabled.")

cfValid(float h, float l) => not na(h) and not na(l) and sdCompare(h, l) >= 0
cfOverlap(float h, float l, float top, float bot) => cfValid(h, l) and cfValid(top, bot) and sdCompare(l, top) <= 0 and sdCompare(h, bot) >= 0
cfPdaAction(int dir, float top, float bot, float h, float l, float c) =>
    int action = 0
    if not cfValid(h, l) or na(c) or sdCompare(c, h) > 0 or sdCompare(c, l) < 0 or not cfValid(top, bot) or sdCompare(top, bot) <= 0
        action := -1
    else if (dir == 1 and sdCompare(c, bot) < 0) or (dir == -1 and sdCompare(c, top) > 0)
        action := -1
    else if cfOverlap(h, l, top, bot)
        action := 1
    action

cfMomentum(int dir, float o, float h, float l, float c, float a, float minBody, float minLocation) =>
    bool valid = cfValid(h, l) and not na(o) and not na(c) and sdCompare(o, l) >= 0 and sdCompare(o, h) <= 0 and sdCompare(c, l) >= 0 and sdCompare(c, h) <= 0 and sdCompare(h, l) > 0 and not na(a) and sdSign(a) > 0
    valid and sdSign((c - o) * dir) > 0 and sdCompare(math.abs(c - o), minBody * a) >= 0 and sdCompare(dir == 1 ? (c - l) / (h - l) : (h - c) / (h - l), minLocation) >= 0

type CfPair
    array<float> ownH
    array<float> ownL
    array<float> peerH
    array<float> peerL
    int stamp = na
    int bullAt = na
    int bearAt = na
    bool ready = false

cfPairReset(CfPair p) =>
    array.clear(p.ownH)
    array.clear(p.ownL)
    array.clear(p.peerH)
    array.clear(p.peerL)
    p.stamp := na
    p.bullAt := na
    p.bearAt := na
    p.ready := false

cfPairStep(CfPair p, int sourceTime, int peerTime, int sourceEnd, int peerEnd, int interval, float ownH, float ownL, float peerH, float peerL, int window, bool inverse, int observedBar) =>
    bool valid = interval > 0 and not na(sourceTime) and sourceTime == peerTime and sourceEnd == peerEnd and sourceEnd - sourceTime == interval and cfValid(ownH, ownL) and cfValid(peerH, peerL)
    if not valid
        cfPairReset(p)
    else if na(p.stamp) or sourceTime != p.stamp
        if not na(p.stamp) and sourceTime != p.stamp + interval
            cfPairReset(p)
        p.ready := array.size(p.ownH) >= window
        if p.ready
            bool ownNewLow = sdCompare(ownL, array.min(p.ownL)) < 0
            bool ownNewHigh = sdCompare(ownH, array.max(p.ownH)) > 0
            bool peerNoLow = sdCompare(peerL, array.min(p.peerL)) >= 0
            bool peerNoHigh = sdCompare(peerH, array.max(p.peerH)) <= 0
            if ownNewLow and (inverse ? peerNoHigh : peerNoLow)
                p.bullAt := observedBar
            if ownNewHigh and (inverse ? peerNoLow : peerNoHigh)
                p.bearAt := observedBar
        array.push(p.ownH, ownH)
        array.push(p.ownL, ownL)
        array.push(p.peerH, peerH)
        array.push(p.peerL, peerL)
        while array.size(p.ownH) > window
            array.shift(p.ownH)
            array.shift(p.ownL)
            array.shift(p.peerH)
            array.shift(p.peerL)
        p.stamp := sourceTime
    p.ready

type CfPda
    float top
    float bot
    int dir
    int formedAt
    bool fresh = true

type CfDelivery
    int bullAt = na
    int bearAt = na
    int bullSource = na
    int bearSource = na
    float bullTop = na
    float bullBot = na
    float bearTop = na
    float bearBot = na
    bool bullUsed = false
    bool bearUsed = false

cfDeliveryObserve(CfDelivery d, CfPda z, float h, float l, float c, int at) =>
    int action = cfPdaAction(z.dir, z.top, z.bot, h, l, c)
    if z.fresh and action != 0
        z.fresh := false
        if action == 1
            if z.dir == 1
                d.bullAt := at
                d.bullSource := z.formedAt
                d.bullTop := z.top
                d.bullBot := z.bot
                d.bullUsed := false
            else
                d.bearAt := at
                d.bearSource := z.formedAt
                d.bearTop := z.top
                d.bearBot := z.bot
                d.bearUsed := false

cfDeliveryReady(CfDelivery d, int dir, int at, int ttl) =>
    int touched = dir == 1 ? d.bullAt : d.bearAt
    bool used = dir == 1 ? d.bullUsed : d.bearUsed
    not used and not na(touched) and at >= touched and at - touched < ttl

cfDeliveryConsume(CfDelivery d, int dir) =>
    if dir == 1
        d.bullUsed := true
    else
        d.bearUsed := true

var array<CfPda> cfPdas = array.new<CfPda>()
var CfDelivery cfDelivery = CfDelivery.new()
var CfPair cfPair = CfPair.new(array.new<float>(), array.new<float>(), array.new<float>(), array.new<float>())
var int cfLastHtf = na
float cfChartSeconds = timeframe.in_seconds()
float cfHtfSeconds = timeframe.in_seconds(inCfHtf)
bool cfHtfAllowed = not na(cfChartSeconds) and not na(cfHtfSeconds) and cfHtfSeconds > cfChartSeconds
[cfH1, cfL1, cfH3, cfL3, cfHtfTime, cfHtfEnd] = request.security(syminfo.tickerid, inCfHtf, [high[1], low[1], high[3], low[3], time[1], time_close[1]], lookahead = barmerge.lookahead_on)
[cfPeerH, cfPeerL, cfPeerTime, cfPeerEnd] = request.security(inCfPair, timeframe.period, [high[1], low[1], time[1], time_close[1]], gaps = barmerge.gaps_on, lookahead = barmerge.lookahead_on, ignore_invalid_symbol = true)
bool cfSourceReady = cfHtfAllowed and cfValid(cfH1, cfL1) and cfValid(cfH3, cfL3) and not na(cfHtfEnd) and cfHtfEnd <= time_close
bool cfMomentumLong = cfMomentum(1, open, high, low, close, atr, inCfBody, inCfLocation)
bool cfMomentumShort = cfMomentum(-1, open, high, low, close, atr, inCfBody, inCfLocation)
bool cfVolume = not na(volume) and sdSign(volume) > 0 and not na(volMa) and sdSign(volMa) > 0 and sdCompare(volume, 1.3 * volMa) >= 0

if barstate.isconfirmed
    if inCfSmt
        cfPairStep(cfPair, time[1], cfPeerTime, time_close[1], cfPeerEnd, int(cfChartSeconds * 1000), high[1], low[1], cfPeerH, cfPeerL, inCfWindow, inCfInverse, bar_index)
    else
        cfPairReset(cfPair)
    if not cfSourceReady
        array.clear(cfPdas)
        cfDelivery.bullAt := na
        cfDelivery.bearAt := na
    else
        if na(cfLastHtf) or cfHtfTime != cfLastHtf
            // A chart bar straddling formation cannot establish post-formation freshness: skip that birth.
            if cfHtfEnd <= time
                if sdCompare(cfL1, cfH3) > 0
                    array.push(cfPdas, CfPda.new(cfL1, cfH3, 1, cfHtfEnd))
                if sdCompare(cfH1, cfL3) < 0
                    array.push(cfPdas, CfPda.new(cfL3, cfH1, -1, cfHtfEnd))
            cfLastHtf := cfHtfTime
        // Oldest-to-newest: if several fresh zones are touched together, the most recently formed wins.
        for z in cfPdas
            bool expired = time_close - z.formedAt > cfHtfSeconds * 1000 * inCfPdaLife
            if expired
                z.fresh := false
            else
                cfDeliveryObserve(cfDelivery, z, high, low, close, bar_index)
        if not na(cfDelivery.bullBot) and sdCompare(close, cfDelivery.bullBot) < 0
            cfDelivery.bullAt := na
        if not na(cfDelivery.bearTop) and sdCompare(close, cfDelivery.bearTop) > 0
            cfDelivery.bearAt := na
        while array.size(cfPdas) > 40
            array.shift(cfPdas)

sdConfluencePda(int dir) => cfSourceReady and cfDeliveryReady(cfDelivery, dir, bar_index, inCfPdaAge)
sdConfluenceSmt(int dir) =>
    int at = dir == 1 ? cfPair.bullAt : cfPair.bearAt
    inCfSmt and cfPair.ready and not na(at) and bar_index >= at and bar_index - at < inCfSmtAge
sdConfluenceMomentum(int dir) => dir == 1 ? cfMomentumLong : cfMomentumShort
sdConfluenceVotes(int dir) => (sdConfluencePda(dir) ? 1 : 0) + (sdConfluenceSmt(dir) ? 1 : 0) + (sdConfluenceMomentum(dir) ? 1 : 0) + (cfVolume ? 1 : 0)
sdConfluenceAllowed(int dir) => (not inCfPda or sdConfluencePda(dir)) and (not inCfSmt or sdConfluenceSmt(dir)) and (not inCfMomentum or sdConfluenceMomentum(dir)) and sdConfluenceVotes(dir) >= inCfVotes
sdConfluenceReason(int dir) =>
    string reason = "confluence ready"
    if inCfPda and not cfSourceReady
        reason := cfHtfAllowed ? "HTF FVG source unavailable" : "HTF FVG timeframe must be higher than chart"
    else if inCfPda and not sdConfluencePda(dir)
        reason := "no unused first-touch HTF FVG delivery"
    else if inCfSmt and not cfPair.ready
        reason := "SMT waiting for aligned closed-bar history"
    else if inCfSmt and not sdConfluenceSmt(dir)
        reason := "no recent paired-market SMT"
    else if inCfMomentum and not sdConfluenceMomentum(dir)
        reason := "confirmation body lacks directional momentum"
    else if sdConfluenceVotes(dir) < inCfVotes
        reason := "observed confluence count below minimum"
    reason
sdConfluenceSummary(int dir) => "PDA " + (sdConfluencePda(dir) ? "first touch" : "unavailable/used") + " · SMT " + (not inCfSmt ? "off" : sdConfluenceSmt(dir) ? "confirmed -1 bar" : "waiting") + " · " + str.tostring(sdConfluenceVotes(dir)) + "/4 observed"
sdConfluenceConsume(int dir) =>
    if sdConfluencePda(dir)
        cfDeliveryConsume(cfDelivery, dir)
// CONFLUENCE FRAGMENT END
bool sdMacroAllowed = sdMacroWindow(inMacro, hour(time_close, inTZ), minute(time_close, inTZ))
sdAdmissionAllowed(int direction) => sdMacroAllowed and sdConfluenceAllowed(direction)
sdAdmissionReason(int direction) => not sdMacroAllowed ? "Confirmation outside selected macro" : sdConfluenceReason(direction)

// ═══════════════════════════════════════════════════════════════════ POOLS (resting liquidity)
type Pool
    float  px
    int    bar
    int    side        // +1 = buy-side liquidity (a high) · −1 = sell-side liquidity (a low)
    string kind        // SWG · EQH · EQL · SES · PDH · PDL · PWH · PWL
    int    touches = 1
    bool   swept   = false
    bool   faded   = false
    int    sweptBar = na
    line   ln
    line   ln2      // glow (strong pools only)
    label  lb
var array<Pool> pools = array.new<Pool>()

poolColor(int side, bool swept) =>
    color base = side == 1 ? th.down : th.up
    swept ? color.new(base, 78) : color.new(base, 35)

poolDelete(Pool p) =>
    if not na(p.ln)
        line.delete(p.ln)
    if not na(p.ln2)
        line.delete(p.ln2)
    if not na(p.lb)
        label.delete(p.lb)

// register (or merge into an equal pool); draw only outside Minimal
poolAdd(float px, int bar, int side, string kind) =>
    bool merged = false
    if kind == "SWG"
        for p in pools
            if not p.swept and (p.kind == "SWG" or p.kind == "EQH" or p.kind == "EQL") and p.side == side and sdCompare(px, p.px - inEqTol * atrU) >= 0 and sdCompare(px, p.px + inEqTol * atrU) <= 0
                p.px := side == 1 ? math.max(p.px, px) : math.min(p.px, px)
                p.kind := side == 1 ? "EQH" : "EQL"
                p.touches := p.touches + 1
                p.bar := math.min(p.bar, bar)
                merged := true
                break
    if not merged
        // one PDH / PDL / PWH / PWL at a time — replace the previous period's pool
        if (kind == "PDH" or kind == "PDL" or kind == "PWH" or kind == "PWL") and array.size(pools) > 0
            for i = array.size(pools) - 1 to 0
                Pool q = array.get(pools, i)
                if q.kind == kind
                    poolDelete(q)
                    array.remove(pools, i)
        Pool p = Pool.new(px = px, bar = bar, side = side, kind = kind)
        array.push(pools, p)
    merged

// Budget caps swing pools; swing and session pools age out. Swept pools linger forty bars.
poolPrune() =>
    int up = 0
    int dn = 0
    for i = array.size(pools) - 1 to 0
        if i < 0
            break
        Pool p = array.get(pools, i)
        bool isSwing = p.kind == "SWG" or p.kind == "EQH" or p.kind == "EQL" or p.kind == "HTF"
        bool old = p.swept ? (bar_index - nz(p.sweptBar, bar_index)) > 40 : ((isSwing or p.kind == "SES") and (bar_index - p.bar) > inPoolAge)
        if old
            poolDelete(p)
            array.remove(pools, i)
        else if not p.swept and isSwing
            if p.side == 1
                up += 1
            else
                dn += 1
    // over budget → drop the oldest resting swing pools
    int i = 0
    while i < array.size(pools)
        int sizeBefore = array.size(pools)
        Pool p = array.get(pools, i)
        bool isSwing = p.kind == "SWG" or p.kind == "EQH" or p.kind == "EQL" or p.kind == "HTF"
        if not p.swept and isSwing
            if p.side == 1 and up > inMaxPools
                poolDelete(p)
                array.remove(pools, i)
                up -= 1
            else if p.side == -1 and dn > inMaxPools
                poolDelete(p)
                array.remove(pools, i)
                dn -= 1
        if array.size(pools) == sizeBefore
            i += 1

// killzone session pools: when a killzone ends, its high / low rest as liquidity
var float k1H = na
var float k1L = na
var float k2H = na
var float k2L = na
var float k3H = na
var float k3L = na
if kz1
    k1H := kz1[1] ? math.max(nz(k1H, high), high) : high
    k1L := kz1[1] ? math.min(nz(k1L, low), low) : low
if kz2
    k2H := kz2[1] ? math.max(nz(k2H, high), high) : high
    k2L := kz2[1] ? math.min(nz(k2L, low), low) : low
if kz3
    k3H := kz3[1] ? math.max(nz(k3H, high), high) : high
    k3L := kz3[1] ? math.min(nz(k3L, low), low) : low

if barstate.isconfirmed and not na(atr)
    if not na(ph)
        poolAdd(ph, bar_index - inPivLen, 1, "SWG")
    if not na(pl)
        poolAdd(pl, bar_index - inPivLen, -1, "SWG")
    if inPoolSes
        if kz1[1] and not kz1 and not na(k1H)
            poolAdd(k1H, bar_index - 1, 1, "SES")
            poolAdd(k1L, bar_index - 1, -1, "SES")
        if kz2[1] and not kz2 and not na(k2H)
            poolAdd(k2H, bar_index - 1, 1, "SES")
            poolAdd(k2L, bar_index - 1, -1, "SES")
        if kz3[1] and not kz3 and not na(k3H)
            poolAdd(k3H, bar_index - 1, 1, "SES")
            poolAdd(k3L, bar_index - 1, -1, "SES")
    if inPoolPD and dayOK and not na(pdStamp) and (na(pdStamp[1]) or pdStamp != pdStamp[1])
        poolAdd(pdh, bar_index, 1, "PDH")
        poolAdd(pdl, bar_index, -1, "PDL")
    if inPoolPW and weekOK and not na(pwStamp) and (na(pwStamp[1]) or pwStamp != pwStamp[1])
        poolAdd(pwh, bar_index, 1, "PWH")
        poolAdd(pwl, bar_index, -1, "PWL")
    if inHtfOn and htfOK
        if not na(htfPh) and (na(htfPh[1]) or htfPh != htfPh[1])
            poolAdd(htfPh, bar_index - inHtfLen * htfMult, 1, "HTF")
        if not na(htfPl) and (na(htfPl[1]) or htfPl != htfPl[1])
            poolAdd(htfPl, bar_index - inHtfLen * htfMult, -1, "HTF")
    poolPrune()

// ═══════════════════════════════════════════════════════════════════ SHORT-TERM SWINGS (the swing a shift must break)
var float stHi    = na
var int   stHiBar = na
var float stLo    = na
var int   stLoBar = na
if not na(phS)
    stHi    := phS
    stHiBar := bar_index - inShiftLeg
if not na(plS)
    stLo    := plS
    stLoBar := bar_index - inShiftLeg

// ═══════════════════════════════════════════════════════════════════ FVG REGISTRY (for the inversion model)
// every 3-candle gap ≥ the minimum size is tracked until a bar CLOSES through it the wrong way (inversion) or it ages out
type Gap
    float top
    float bot
    int   dir            // +1 bullish gap · −1 bearish gap
    int   bar
    bool  inverted = false
    int   invBar   = na
    int   lastBar  = na
    int   members  = 1
var array<Gap> gaps = array.new<Gap>()
gapRegister(float top, float bot, int direction, int birth) =>
    bool joined = false
    if inGapMode == "Series" and array.size(gaps) > 0
        Gap previous = array.get(gaps, array.size(gaps) - 1)
        if sdSeriesJoin(true, direction, previous.dir, birth, previous.lastBar, previous.inverted)
            previous.top := math.max(previous.top, top)
            previous.bot := math.min(previous.bot, bot)
            previous.lastBar := birth
            previous.members += 1
            joined := true
    if not joined
        array.push(gaps, Gap.new(top = top, bot = bot, dir = direction, bar = birth, lastBar = birth))
if barstate.isconfirmed and not na(atr)
    if sdGapValid(low, high[2], inFvgMin * atrU)
        gapRegister(low, high[2], 1, bar_index - 1)
    if sdGapValid(low[2], high, inFvgMin * atrU)
        gapRegister(low[2], high, -1, bar_index - 1)
    for i = array.size(gaps) - 1 to 0
        if i < 0
            break
        Gap g = array.get(gaps, i)
        if bar_index - g.bar > 200
            array.remove(gaps, i)
        else if not g.inverted
            if sdInversion(g.dir, close, g.top, g.bot)
                g.inverted := true
                g.invBar := bar_index
    while array.size(gaps) > 60
        array.shift(gaps)

// sweep quality 0..1 — DESCRIPTIVE (depth of the raid, strength of the pool, killzone, volume). Never gates anything.
poolWeight(string kind) => kind == "SWG" ? 0.45 : (kind == "EQH" or kind == "EQL") ? 0.8 : kind == "SES" ? 0.75 : 1.0
sweepQuality(float depthAtr, string kind, bool kz) =>
    float volTerm = volMa > 0 ? math.min(nz(volume, 0.0) / volMa / 2.0, 1.0) : 0.5
    clamp01(0.35 * math.min(depthAtr / 0.5, 1.0) + 0.35 * poolWeight(kind) + 0.15 * (kz ? 1.0 : 0.0) + 0.15 * volTerm)

// ═══════════════════════════════════════════════════════════════════ THE SETUP STATE MACHINE
// states: 0 SCANNING · 1 SWEPT · 2 SHIFTED · 3 ARMED (waiting for the fill) · 4 FILLED (trade open)
type Setup
    int    dir      = 0
    int    state    = 0
    float  poolPx   = na
    string poolKind = ""
    float  ext      = na        // the sweep extreme — structural invalidation
    int    sweepBar = na
    bool   sweepKz  = false
    float  sweepDepth = na
    float  oppSwing = na
    int    oppBar   = na
    int    mssBar   = na
    float  mssPx    = na
    float  fvgTop   = na
    float  fvgBot   = na
    int    fvgBar   = na
    float  entry    = na
    float  sl       = na
    float  sl0      = na
    float  r        = na
    float  tp1      = na
    float  tp2      = na
    float  tp3      = na
    float  tpLiq    = na
    string tpLiqKind = ""
    float  tpFinal  = na
    int    armBar   = na
    int    fillBar  = na
    bool   tp1Hit   = false
    bool   tp2Hit   = false
    string model    = ""        // "iFVG" or "MSS"
    float  quality  = na        // sweep quality 0..1 (descriptive, frozen at the raid)
    int    armTime = na
    int    entryTime = na
    int    endTime = na
    int    planId = na
    bool   planValid = false
    string planReason = "No plan"
    bool   ambiguous = false
    string confluenceSummary = ""
    string stopBasis = ""
type Stat
    int   swept    = 0
    int   shifted  = 0
    int   armed    = 0
    int   filled   = 0
    int   n        = 0
    int   wins     = 0
    int   scratch  = 0
    float sumR     = 0.0
    int   ambiguous = 0
    int   nSwg = 0
    int   wSwg = 0
    int   nEq  = 0
    int   wEq  = 0
    int   nSes = 0
    int   wSes = 0
    int   nHtf = 0
    int   wHtf = 0
    int   nIfvg = 0
    int   wIfvg = 0
    int   nMss  = 0
    int   wMss  = 0
var Setup s  = Setup.new()
var Stat  st = Stat.new()

// Frozen completed paper episodes; never aliases the mutable current Setup.
type SdPlanRecord
    int planId
    int direction
    string model
    string poolKind
    float poolPrice
    float zoneHigh
    float zoneLow
    float entry
    float initialStop
    float target
    float risk
    int armTime
    int entryTime
    int endTime
    float exitPrice
    float grossR
    string outcome
    bool ambiguous
    string confluences
var array<SdPlanRecord> sdHistory = array.new<SdPlanRecord>()
var int sdLastClosedTime = na
var float sdLastClosedPrice = na
if barstate.isconfirmed
    sdLastClosedTime := time_close
    sdLastClosedPrice := close

// per-bar event record (a fresh object every bar; functions mutate its fields — globals cannot be rebound in functions)
type Ev
    bool   sweep  = false
    bool   shift  = false
    bool   armed  = false
    bool   filled = false
    bool   exit   = false
    bool   cancel = false
    bool   tp1    = false
    bool   tp2    = false
    string why    = ""
    float  r      = na
    float  px     = na
    int    dir    = 0
Ev ev = Ev.new()

// nearest resting pool on the opposite side, at least minR × r away → the liquidity target
liqTarget(int dir, float entry, float r) =>
    float best = na
    string bk = ""
    for p in pools
        if not p.swept and (inTargetQuality == "Any fresh pool" or p.touches >= 2)
            float targetPx = sdTickOut(p.px, -dir, syminfo.mintick)
            if dir == 1 and p.side == 1 and sdCompare(targetPx, entry + inTpLiqMinR * r) >= 0
                if na(best) or sdCompare(targetPx, best) < 0
                    best := targetPx
                    bk := p.kind
            if dir == -1 and p.side == -1 and sdCompare(targetPx, entry - inTpLiqMinR * r) <= 0
                if na(best) or sdCompare(targetPx, best) > 0
                    best := targetPx
                    bk := p.kind
    [best, bk]

// FVG search inside the shift leg (offsets 0..len): bull = low[i] > high[i+2]
findFvg(int dir, int len) =>
    float top = na
    float bot = na
    int   at  = na
    int   lim = math.min(math.max(len, 0), 60)
    for i = 0 to lim
        bool bull = dir == 1 and sdGapValid(low[i], high[i + 2], inFvgMin * atrU)
        bool bear = dir == -1 and sdGapValid(low[i + 2], high[i], inFvgMin * atrU)
        if bull or bear
            float t = bull ? low[i] : low[i + 2]
            float b = bull ? high[i + 2] : high[i]
            if sdGapValid(t, b, inFvgMin * atrU)
                if inFvgPick == "Nearest"
                    top := t
                    bot := b
                    at  := bar_index - i
                    break
                else
                    top := t
                    bot := b
                    at  := bar_index - i
    [top, bot, at]

setLevels(int dir, float entry, bool checkAdmission = true) =>
    float zoneFar = dir == 1 ? s.fvgBot : s.fvgTop
    bool extNear = sdCompare(entry, s.ext - inSlMaxAtr * atrU) >= 0 and sdCompare(entry, s.ext + inSlMaxAtr * atrU) <= 0
    s.stopBasis := inSlMode == "Sweep extreme" ? "sweep extreme" : inSlMode == "Zone far edge" ? "zone far edge" : extNear ? "Auto: sweep extreme" : "Auto: zone far edge"
    float anchor = inSlMode == "Sweep extreme" ? s.ext : inSlMode == "Zone far edge" ? zoneFar : (extNear ? s.ext : zoneFar)
    [slPx, risk, t1, t2, t3, valid, reason] = sdPlanGeometry(dir, entry, anchor, atrU, syminfo.mintick, inSlBuf, inTp1, inTp2, inTp3)
    s.entry := entry
    s.sl := slPx
    s.sl0 := slPx
    s.r := risk
    s.tp1 := t1
    s.tp2 := t2
    s.tp3 := t3
    [lq, lk] = liqTarget(dir, entry, risk)
    s.tpLiq := lq
    s.tpLiqKind := lk
    s.tpFinal := inPrimary == "Liquidity" and not na(lq) ? lq : t3
    s.planValid := valid and (not checkAdmission or sdAdmissionAllowed(dir))
    s.planReason := checkAdmission and not sdAdmissionAllowed(dir) ? sdAdmissionReason(dir) : reason
    s.ambiguous := false
    valid

closeTrade(float px, string why) =>
    float rr = (px - s.entry) * s.dir / s.r
    ev.exit := true
    ev.why  := why
    ev.r    := rr
    ev.px   := px
    ev.dir  := s.dir
    s.endTime := time_close
    array.unshift(sdHistory, SdPlanRecord.new(s.planId, ev.dir, s.model, s.poolKind, s.poolPx, s.fvgTop, s.fvgBot, s.entry, s.sl0, s.tpFinal, s.r, s.armTime, s.entryTime, s.endTime, px, rr, why, s.ambiguous, s.confluenceSummary))
    if array.size(sdHistory) > 60
        array.pop(sdHistory)
    if s.ambiguous
        st.ambiguous += 1
    bool win = rr >= 0.05
    st.n := st.n + 1
    st.wins := st.wins + (win ? 1 : 0)
    st.scratch := st.scratch + (math.abs(rr) < 0.05 ? 1 : 0)
    st.sumR := st.sumR + rr
    if s.poolKind == "SWG"
        st.nSwg := st.nSwg + 1
        st.wSwg := st.wSwg + (win ? 1 : 0)
    else if s.poolKind == "EQH" or s.poolKind == "EQL"
        st.nEq := st.nEq + 1
        st.wEq := st.wEq + (win ? 1 : 0)
    else if s.poolKind == "SES"
        st.nSes := st.nSes + 1
        st.wSes := st.wSes + (win ? 1 : 0)
    else
        st.nHtf := st.nHtf + 1
        st.wHtf := st.wHtf + (win ? 1 : 0)
    if s.model == "iFVG"
        st.nIfvg := st.nIfvg + 1
        st.wIfvg := st.wIfvg + (win ? 1 : 0)
    else
        st.nMss := st.nMss + 1
        st.wMss := st.wMss + (win ? 1 : 0)
    s.state := 0
    s.dir := 0

cancelSetup(string why) =>
    ev.cancel := true
    ev.why := why
    ev.dir := s.dir
    s.endTime := time_close
    s.state := 0
    s.dir := 0

bool dirL = inDir != "Short only"
bool dirS = inDir != "Long only"

float observedRaidHi = na
float observedRaidLo = na
string observedKindHi = ""
string observedKindLo = ""
if barstate.isconfirmed and not na(atr)
    for p in pools
        if not p.swept
            [traversed, raid] = sdPoolObservation(p.side, p.px, high, low, close, inSweepMin * atrU)
            if traversed
                p.swept := true
                p.sweptBar := bar_index
                if raid and (inPoolMin == "Any pool" or p.kind != "SWG")
                    if p.side == 1 and (na(observedRaidHi) or sdCompare(p.px, observedRaidHi) > 0)
                        observedRaidHi := p.px
                        observedKindHi := p.kind
                    if p.side == -1 and (na(observedRaidLo) or sdCompare(p.px, observedRaidLo) < 0)
                        observedRaidLo := p.px
                        observedKindLo := p.kind

if barstate.isconfirmed and not na(atr) and bar_index > 50
    // FILLED: next-bar open ordering, then ambiguous range, then timeout. New stops apply next bar.
    if s.state == 4
        int d = s.dir
        [ended, exitPx, outcome, ambiguous] = sdOutcome(d, s.sl, s.tpFinal, open, high, low, close, s.fillBar, bar_index, inMaxTrade)
        if ended
            s.ambiguous := s.ambiguous or ambiguous
            string reason = outcome == "TARGET" ? ((not na(s.tpLiq) and sdCompare(s.tpFinal, s.tpLiq) == 0) ? "TP-LIQ" : "TP3") : outcome == "STOP" ? (s.tp2Hit ? "TRAIL" : (s.tp1Hit and inBE) ? "BE" : "SL") : outcome
            closeTrade(exitPx, reason + (ambiguous ? " · AMBIGUOUS" : ""))
        else
            [nextStop, one, two, newOne, newTwo] = sdMilestones(d, s.entry, s.sl, s.tp1, s.tp2, high, low, s.tp1Hit, s.tp2Hit, inBE, syminfo.mintick)
            s.sl := nextStop
            s.tp1Hit := one
            s.tp2Hit := two
            ev.tp1 := newOne
            ev.tp2 := newTwo
    // ── 3 · ARMED: waiting for the fill
    else if s.state == 3
        int d = s.dir
        [closeInsideZone, invalid, closeEntryAllowed] = sdPendingCloseDecision(d, close, s.fvgTop, s.fvgBot, s.sl)
        bool missed  = d == 1 ? sdCompare(high, s.tp1) >= 0 : sdCompare(low, s.tp1) <= 0
        bool fill = false
        float fillPx = s.entry
        if inEntryMode == "Close inside"
            fill := closeEntryAllowed
            fillPx := close
        else
            fill := d == 1 ? sdCompare(low, s.entry) <= 0 : sdCompare(high, s.entry) >= 0
            fillPx := d == 1 ? math.min(s.entry, open) : math.max(s.entry, open)
        bool gapPast = inEntryMode != "Close inside" and (d == 1 ? sdCompare(open, s.sl) <= 0 : sdCompare(open, s.sl) >= 0)
        if inEntryMode == "Close inside" and invalid
            cancelSetup("structural stop lost before close entry")
        else if fill and gapPast
            // Paper convention: limit and initial stop resolve at the same known open; 0R before costs, not a promised live scratch.
            s.entry := open
            s.state := 4
            s.fillBar := bar_index
            s.entryTime := time
            st.filled := st.filled + 1
            ev.filled := true
            ev.dir := d
            closeTrade(open, "GAP")
        else if fill
            if inEntryMode == "Close inside"
                setLevels(d, fillPx, false)
            else
                // A better limit fill changes actual risk, never the already-resting bracket.
                s.entry := fillPx
                s.r := sdDistance(s.sl0, fillPx, d, syminfo.mintick)
                s.planValid := sdCompare(s.r, 0) > 0
            if not s.planValid
                cancelSetup("Plan rejected: " + s.planReason)
            else
                s.state := 4
                s.fillBar := bar_index
                s.entryTime := time_close
                s.endTime := na
                st.filled += 1
                ev.filled := true
                ev.dir := d
                // A close entry cannot be stopped by earlier wicks. Resting limits may fill intrabar.
                if inEntryMode != "Close inside"
                    bool knownOpenFill = sdCompare(open, fillPx) == 0
                    if knownOpenFill
                        [endedAtOpenBar, priceAtOpenBar, reasonAtOpenBar, ambiguousAtOpenBar] = sdOutcome(d, s.sl, s.tpFinal, open, high, low, close, bar_index - 1, bar_index, inMaxTrade)
                        if endedAtOpenBar
                            s.ambiguous := ambiguousAtOpenBar
                            closeTrade(priceAtOpenBar, reasonAtOpenBar == "TARGET" ? ((not na(s.tpLiq) and sdCompare(s.tpFinal, s.tpLiq) == 0) ? "TP-LIQ" : "TP3") : "SL" + (ambiguousAtOpenBar ? " · AMBIGUOUS" : ""))
                        else
                            [nextStop, one, two, newOne, newTwo] = sdMilestones(d, s.entry, s.sl, s.tp1, s.tp2, high, low, false, false, inBE, syminfo.mintick)
                            s.sl := nextStop
                            s.tp1Hit := one
                            s.tp2Hit := two
                            ev.tp1 := newOne
                            ev.tp2 := newTwo
                    else
                        bool stopSame = d == 1 ? sdCompare(low, s.sl) <= 0 : sdCompare(high, s.sl) >= 0
                        bool targetSame = d == 1 ? sdCompare(high, s.tpFinal) >= 0 : sdCompare(low, s.tpFinal) <= 0
                        // An intrabar limit has unknown entry-vs-target chronology. Stop-first; do not invent a same-bar target win.
                        s.ambiguous := stopSame or targetSame
                        if stopSame
                            closeTrade(s.sl, "SL · AMBIGUOUS")
        else if invalid
            cancelSetup("invalidated before fill")
        else if s.model == "iFVG" and (d == 1 ? sdCompare(close, s.fvgBot) < 0 : sdCompare(close, s.fvgTop) > 0)
            cancelSetup("inversion failed — closed back through the zone")
        else if missed
            cancelSetup("missed — ran to TP1 unfilled")
        else if bar_index - s.armBar >= inExpire
            cancelSetup("expired unfilled")
    // ── 2 · SHIFTED: looking for the FVG in the leg
    else if s.state == 2
        int d = s.dir
        bool failed = d == 1 ? sdCompare(close, s.ext) < 0 : sdCompare(close, s.ext) > 0
        if failed
            cancelSetup("sweep extreme lost after the shift")
        else if bar_index - s.mssBar > inFvgWin
            cancelSetup("no FVG after the shift")
        else
            [ft, fb, fa] = findFvg(d, bar_index - s.sweepBar)
            if not na(ft)
                s.fvgTop := ft
                s.fvgBot := fb
                s.fvgBar := fa
                s.model := "MSS"
                float e = inEntryMode == "Confirmation close" ? close : inEntryMode == "CE (50%)" ? (ft + fb) / 2 : (d == 1 ? ft : fb)
                setLevels(d, e)
                if s.planValid
                    s.state := 3
                    s.armBar := bar_index
                    s.armTime := time_close
                    s.planId := time_close
                    s.entryTime := na
                    s.endTime := na
                    st.armed += 1
                    ev.armed := true
                else
                    cancelSetup("Plan rejected: " + s.planReason)
    // ── 1 · SWEPT: waiting for the confirmation (iFVG inversion and / or structure shift)
    else if s.state == 1
        int d = s.dir
        // the invalidation level follows the leg's extreme while we wait
        s.ext := d == 1 ? math.min(s.ext, low) : math.max(s.ext, high)
        bool failed = d == 1 ? sdCompare(close, s.poolPx - inDispAtr * atrU) < 0 : sdCompare(close, s.poolPx + inDispAtr * atrU) > 0
        if failed
            cancelSetup("no rejection — price kept going")
        else if bar_index - s.sweepBar > inShiftWin
            cancelSetup("no shift in time")
        else
            bool useIfvg = inConfirm != "Structure shift (MSS)"
            bool useMss  = inConfirm != "FVG inversion (iFVG)"
            bool shift = useMss and (d == 1 ? (not na(s.oppSwing) and sdCompare(close, s.oppSwing) > 0) : (not na(s.oppSwing) and sdCompare(close, s.oppSwing) < 0))
            bool disp  = sdDisplacement(d, close, s.ext, inDispAtr * atrU)
            // iFVG: an opposing gap printed near the raid that THIS bar closed through → it inverts; the nearest one to price is the zone
            float igTop = na
            float igBot = na
            int   igBar = na
            if useIfvg
                for g in gaps
                    if g.inverted and g.invBar == bar_index and g.dir == -d and g.bar >= s.sweepBar - inIfvgBack
                        bool beyondExt = d == 1 ? sdCompare(g.bot, s.ext) > 0 : sdCompare(g.top, s.ext) < 0
                        if beyondExt and (na(igTop) or (d == 1 ? sdCompare(g.top, igTop) > 0 : sdCompare(g.bot, igBot) < 0))
                            igTop := g.top
                            igBot := g.bot
                            igBar := g.bar
            if not na(igTop) and disp
                s.fvgTop := igTop
                s.fvgBot := igBot
                s.fvgBar := igBar
                s.model := "iFVG"
                s.mssBar := bar_index
                s.mssPx := d == 1 ? igTop : igBot
                st.shifted := st.shifted + 1
                ev.shift := true
                float e = inEntryMode == "Confirmation close" ? close : inEntryMode == "CE (50%)" ? (igTop + igBot) / 2 : (d == 1 ? igTop : igBot)
                setLevels(d, e)
                if s.planValid
                    s.state := 3
                    s.armBar := bar_index
                    s.armTime := time_close
                    s.planId := time_close
                    s.entryTime := na
                    s.endTime := na
                    st.armed += 1
                    ev.armed := true
                else
                    cancelSetup("Plan rejected: " + s.planReason)
            else if shift and disp
                s.state := 2
                s.mssBar := bar_index
                s.mssPx := s.oppSwing
                st.shifted := st.shifted + 1
                ev.shift := true
                // the FVG may already be in the leg — arm on the same bar
                [ft, fb, fa] = findFvg(d, bar_index - s.sweepBar)
                if not na(ft)
                    s.fvgTop := ft
                    s.fvgBot := fb
                    s.fvgBar := fa
                    s.model := "MSS"
                    float e = inEntryMode == "Confirmation close" ? close : inEntryMode == "CE (50%)" ? (ft + fb) / 2 : (d == 1 ? ft : fb)
                    setLevels(d, e)
                    if s.planValid
                        s.state := 3
                        s.armBar := bar_index
                        s.armTime := time_close
                        s.planId := time_close
                        s.entryTime := na
                        s.endTime := na
                        st.armed += 1
                        ev.armed := true
                    else
                        cancelSetup("Plan rejected: " + s.planReason)
    if ev.armed
        s.confluenceSummary := sdConfluenceSummary(s.dir)
        sdConfluenceConsume(s.dir)
    // Confirmation-close orders are accepted at this close; no earlier OHLC can end this plan.
    if ev.armed and inEntryMode == "Confirmation close" and s.planValid
        s.state := 4
        s.fillBar := bar_index
        s.entryTime := time_close
        st.filled += 1
        ev.filled := true
        ev.dir := s.dir
    // ── 0/1/2 · a fresh sweep replaces an idea that has not armed yet; an ARMED limit stands until filled / invalidated / missed / expired; a FILLED trade is never interrupted
    if s.state != 4 and s.state != 3 and not ev.exit and not ev.cancel and (not inSweepKz or inKz) and volOK
        float bestHi = observedRaidHi
        float bestLo = observedRaidLo
        string bkHi = observedKindHi
        string bkLo = observedKindLo
        // sell-side liquidity raided (a low taken, close back above) → long idea
        if not na(bestLo) and dirL
            s.dir := 1
            s.state := 1
            s.poolPx := bestLo
            s.poolKind := bkLo
            s.ext := low
            s.sweepBar := bar_index
            s.sweepKz := inKz
            s.sweepDepth := (bestLo - low) / atrU
            s.quality := sweepQuality(s.sweepDepth, bkLo, inKz)
            s.model := ""
            s.planId := na
            s.planValid := false
            s.planReason := "Waiting for confirmation"
            s.confluenceSummary := ""
            s.stopBasis := ""
            s.entry := na
            s.sl := na
            s.sl0 := na
            s.r := na
            s.tp1 := na
            s.tp2 := na
            s.tp3 := na
            s.tpLiq := na
            s.tpFinal := na
            s.armTime := na
            s.entryTime := na
            s.endTime := na
            s.ambiguous := false
            s.oppSwing := stHi
            s.oppBar := stHiBar
            s.tp1Hit := false
            s.tp2Hit := false
            st.swept := st.swept + 1
            ev.sweep := true
        else if not na(bestHi) and dirS
            s.dir := -1
            s.state := 1
            s.poolPx := bestHi
            s.poolKind := bkHi
            s.ext := high
            s.sweepBar := bar_index
            s.sweepKz := inKz
            s.sweepDepth := (high - bestHi) / atrU
            s.quality := sweepQuality(s.sweepDepth, bkHi, inKz)
            s.model := ""
            s.planId := na
            s.planValid := false
            s.planReason := "Waiting for confirmation"
            s.confluenceSummary := ""
            s.stopBasis := ""
            s.entry := na
            s.sl := na
            s.sl0 := na
            s.r := na
            s.tp1 := na
            s.tp2 := na
            s.tp3 := na
            s.tpLiq := na
            s.tpFinal := na
            s.armTime := na
            s.entryTime := na
            s.endTime := na
            s.ambiguous := false
            s.oppSwing := stLo
            s.oppBar := stLoBar
            s.tp1Hit := false
            s.tp2Hit := false
            st.swept := st.swept + 1
            ev.sweep := true

// ═══════════════════════════════════════════════════════════════════ CHART · pools, FVG, MSS, sweeps (drawings)
var array<box>   fvgBoxes = array.new<box>()
var array<line>  mssLines = array.new<line>()
var array<label> tags     = array.new<label>()
capBoxes(int cap) =>
    while array.size(fvgBoxes) > cap
        box.delete(array.shift(fvgBoxes))
capLines(int cap) =>
    while array.size(mssLines) > cap
        line.delete(array.shift(mssLines))
capTags(int cap) =>
    while array.size(tags) > cap
        label.delete(array.shift(tags))

bool showPools = vClean
bool showTags  = vPro
// a zone drawn as four stacked bands, densest at the edge price touches first (top for a long, bottom for a short), plus an outline
gradZone(int dir, float top, float bot, int x1, int x2) =>
    float h = (top - bot) / 4
    for k = 0 to 3
        float bTop = top - h * k
        float bBot = bTop - h
        int alpha = dir == 1 ? 80 + k * 4 : 92 - k * 4
        array.push(fvgBoxes, box.new(x1, bTop, x2, bBot, border_color = color.new(th.bg, 100), bgcolor = color.new(dir == 1 ? th.up : th.down, alpha)))
    array.push(fvgBoxes, box.new(x1, top, x2, bot, border_color = color.new(dir == 1 ? th.up : th.down, 40), bgcolor = color.new(th.bg, 100)))
if barstate.isconfirmed
    if ev.sweep and showTags
        array.push(tags, label.new(bar_index, s.dir == 1 ? low : high, (s.dir == 1 ? "SSL swept · " : "BSL swept · ") + s.poolKind, style = s.dir == 1 ? label.style_label_up : label.style_label_down, color = color.new(th.bg, 20), textcolor = s.dir == 1 ? th.up : th.down, size = size.tiny))
    if ev.shift and vClean and s.model == "MSS"
        array.push(mssLines, line.new(s.oppBar, s.mssPx, bar_index, s.mssPx, color = color.new(s.dir == 1 ? th.up : th.down, 25), style = line.style_dashed, width = 1))
        if showTags
            array.push(tags, label.new(bar_index, s.mssPx, "MSS", style = s.dir == 1 ? label.style_label_down : label.style_label_up, color = color.new(th.bg, 20), textcolor = s.dir == 1 ? th.up : th.down, size = size.tiny))
    if ev.armed and vClean
        gradZone(s.dir, s.fvgTop, s.fvgBot, s.fvgBar - 1, bar_index + inExtend)
        if showTags
            array.push(tags, label.new(s.fvgBar - 1, s.dir == 1 ? s.fvgTop : s.fvgBot, s.model == "iFVG" ? "iFVG ↺" : "FVG", style = label.style_label_right, color = color.new(th.bg, 100), textcolor = color.new(s.dir == 1 ? th.up : th.down, 20), size = size.tiny))
    capBoxes(40)
    capLines(30)
    capTags(60)

// pool lines: created once; x2 refreshed every 20 bars while resting (cheap); frozen + faded ONCE when swept.
// Far pools are deleted on the last bar, not just hidden — TradingView's auto-scale counts every line object.
if barstate.isconfirmed or barstate.islast
    for p in pools
        if showPools
            if na(p.ln)
                p.ln := line.new(math.max(p.bar, bar_index - 250), p.px, bar_index + inExtend + 20, p.px, color = poolColor(p.side, false), width = p.kind == "EQH" or p.kind == "EQL" ? 2 : 1, style = line.style_solid)
                if inGlow and p.kind != "SWG"
                    p.ln2 := line.new(math.max(p.bar, bar_index - 250), p.px, bar_index + inExtend + 20, p.px, color = color.new(p.side == 1 ? th.down : th.up, 88), width = 5, style = line.style_solid)
            if p.swept and not p.faded
                line.set_x2(p.ln, nz(p.sweptBar, bar_index))
                line.set_color(p.ln, poolColor(p.side, true))
                line.set_style(p.ln, line.style_dotted)
                if not na(p.ln2)
                    line.delete(p.ln2)
                    p.ln2 := na
                p.faded := true
            else if not p.swept and bar_index % 20 == 0
                line.set_x2(p.ln, bar_index + inExtend + 20)
                if not na(p.ln2)
                    line.set_x2(p.ln2, bar_index + inExtend + 20)
        else
            if not na(p.ln)
                line.delete(p.ln)
                p.ln := na
            if not na(p.ln2)
                line.delete(p.ln2)
                p.ln2 := na
// focus + labels: last bar only (display)
if barstate.islast
    for p in pools
        bool near = math.abs(p.px - close) <= inPoolNear * atrU
        if showPools and near
            if not na(p.ln) and not p.swept
                line.set_x2(p.ln, bar_index + inExtend)
                if not na(p.ln2)
                    line.set_x2(p.ln2, bar_index + inExtend)
            if showTags and not p.swept
                string ptxt = (p.side == 1 ? "BSL" : "SSL") + " · " + p.kind + (p.touches > 1 ? " ×" + str.tostring(p.touches) : "")
                if na(p.lb)
                    p.lb := label.new(bar_index + inExtend, p.px, ptxt, style = label.style_label_left, color = color.new(th.bg, 100), textcolor = color.new(p.side == 1 ? th.down : th.up, 25), size = size.tiny)
                else
                    label.set_xy(p.lb, bar_index + inExtend, p.px)
                    label.set_text(p.lb, ptxt)
            else if not na(p.lb)
                label.delete(p.lb)
                p.lb := na
        else
            if not na(p.ln)
                line.delete(p.ln)
                p.ln := na
                p.faded := false
            if not na(p.ln2)
                line.delete(p.ln2)
                p.ln2 := na
            if not na(p.lb)
                label.delete(p.lb)
                p.lb := na

// ═══════════════════════════════════════════════════════════════════ CHART · the ticket (plots + gradient zones)
bool pend   = s.state == 3
bool live   = s.state == 4 or ev.exit
int  zDir   = s.dir != 0 ? s.dir : ev.dir
float pEntry = pend ? s.entry : na
float pStop  = pend ? s.sl : na
float pTpF   = pend ? s.tpFinal : na
float aEntry = live ? s.entry : na
float aStop  = live ? s.sl : na
float aTp1   = live ? s.tp1 : na
float aTp2   = live ? s.tp2 : na
float aTpF   = live ? s.tpFinal : na
float aTpLiq = live and not na(s.tpLiq) and sdCompare(s.tpLiq, s.tpFinal) != 0 ? s.tpLiq : na
plot(pEntry, "Pending · entry", color = color.new(th.txtHi, 45), linewidth = 1, style = plot.style_circles, display = display.pane)
plot(pStop,  "Pending · stop",  color = color.new(th.down, 45),  linewidth = 1, style = plot.style_circles, display = display.pane)
plot(pTpF,   "Pending · target", color = color.new(th.up, 45),   linewidth = 1, style = plot.style_circles, display = display.pane)
plEnt  = plot(aEntry, "Trade · entry",  color = color.new(th.txtHi, 10), linewidth = 1, style = plot.style_linebr, display = display.pane)
plStop = plot(aStop,  "Trade · stop",   color = color.new(th.down, 10),  linewidth = 1, style = plot.style_linebr, display = display.pane)
plot(aTp1, "Trade · TP1", color = color.new(th.up, 60), linewidth = 1, style = plot.style_linebr, display = display.pane)
plot(aTp2, "Trade · TP2", color = color.new(th.up, 45), linewidth = 1, style = plot.style_linebr, display = display.pane)
plFin  = plot(aTpF,   "Trade · final target", color = color.new(th.up, 10), linewidth = 1, style = plot.style_linebr, display = display.pane)
plot(aTpLiq, "Trade · liquidity target", color = color.new(th.accentHi, 25), linewidth = 1, style = plot.style_linebr, display = display.pane)
bool zonesOn = inZones and live
fill(plEnt, plStop, top_value = math.max(aEntry, aStop), bottom_value = math.min(aEntry, aStop),
     top_color = zonesOn and zDir == 1 ? color.new(th.down, 78) : color.new(th.down, 100), bottom_color = zonesOn and zDir == -1 ? color.new(th.down, 78) : color.new(th.down, 100), title = "Risk zone", fillgaps = false)
fill(plEnt, plFin, top_value = math.max(aEntry, aTpF), bottom_value = math.min(aEntry, aTpF),
     top_color = zonesOn and zDir == -1 ? color.new(th.up, 80) : color.new(th.up, 100), bottom_color = zonesOn and zDir == 1 ? color.new(th.up, 80) : color.new(th.up, 100), title = "Reward zone", fillgaps = false)
bgcolor(vFull ? (kz1 ? color.new(th.accent, 94) : kz2 ? color.new(th.up, 95) : kz3 ? color.new(th.down, 95) : na) : na, title = "Killzone shading")
barcolor(inTint and s.state == 4 ? color.new(s.dir == 1 ? th.up : th.down, 55) : na, title = "Trade tint")
plotshape(ev.sweep and s.dir == 1 and vClean,  "Raid · sell-side", shape.circle, location.belowbar, color = color.new(th.up, 35),   size = size.tiny, display = display.pane)
plotshape(ev.sweep and s.dir == -1 and vClean, "Raid · buy-side",  shape.circle, location.abovebar, color = color.new(th.down, 35), size = size.tiny, display = display.pane)
plotshape(ev.filled and ev.dir == 1,  "Filled · long",  shape.triangleup,   location.belowbar, color = th.up,   size = size.tiny, display = display.pane)
plotshape(ev.filled and ev.dir == -1, "Filled · short", shape.triangledown, location.abovebar, color = th.down, size = size.tiny, display = display.pane)

// event labels (confirmed bars only by construction)
if ev.armed
    label.new(s.fvgBar, s.dir == 1 ? s.fvgBot : s.fvgTop, (s.dir == 1 ? "LONG armed" : "SHORT armed") + " · " + fmt(s.entry), style = s.dir == 1 ? label.style_label_up : label.style_label_down, color = color.new(s.dir == 1 ? th.up : th.down, 20), textcolor = th.bg, size = size.small,
         tooltip = "entry " + fmt(s.entry) + "\nstop " + fmt(s.sl) + " (" + fmt(s.r) + " = 1R, " + (s.sl0 == s.ext - inSlBuf * atrU or s.sl0 == s.ext + inSlBuf * atrU ? "sweep extreme" : "zone far edge") + ")\nzone " + (s.model == "iFVG" ? "inverted gap " : "FVG ") + fmt(s.fvgBot) + " – " + fmt(s.fvgTop) + "\nTP1 " + fmt(s.tp1) + " · TP2 " + fmt(s.tp2) + " · TP3 " + fmt(s.tp3) + (na(s.tpLiq) ? "\nno liquidity target ≥ " + str.tostring(inTpLiqMinR, "0.0") + "R" : "\nliquidity " + fmt(s.tpLiq) + " (" + s.tpLiqKind + ", " + str.tostring(math.abs(s.tpLiq - s.entry) / s.r, "0.0") + "R)"))
if ev.exit
    string rTxt = (ev.r >= 0 ? "+" : "") + str.tostring(ev.r, "0.0") + "R"
    label.new(bar_index, ev.dir == 1 ? high : low, (ev.dir == 1 ? "XL" : "XS") + " · " + ev.why + " " + rTxt, style = ev.dir == 1 ? label.style_label_down : label.style_label_up, color = color.new(th.bg, 15), textcolor = ev.r >= 0 ? th.up : th.down, size = size.tiny)
if ev.cancel and vPro
    label.new(bar_index, ev.dir == 1 ? high : low, "✕ " + ev.why, style = ev.dir == 1 ? label.style_label_down : label.style_label_up, color = color.new(th.bg, 40), textcolor = th.flat, size = size.tiny)

// ═══════════════════════════════════════════════════════════════════ WORDS (one source of truth)
string stateWord = s.state == 4 ? "FILLED · " + (s.dir == 1 ? "LONG" : "SHORT") : s.state == 3 ? "ARMED · " + (s.dir == 1 ? "LONG" : "SHORT") : s.state == 2 ? "SHIFTED · " + (s.dir == 1 ? "long" : "short") + " idea" : s.state == 1 ? "SWEPT · waiting for confirmation" : "SCANNING"
color  stateColor = s.state >= 3 ? (s.dir == 1 ? th.up : th.down) : s.state > 0 ? th.accentHi : th.txt
string clockTxt  = na(sdLastClosedTime) ? "waiting close" : str.format_time(sdLastClosedTime, "HH:mm", inTZ)
var int restingUp = 0
var int restingDn = 0
if barstate.islast
    restingUp := 0
    restingDn := 0
    for p in pools
        if not p.swept
            if p.side == 1
                restingUp += 1
            else
                restingDn += 1
float rrLiq = s.state >= 3 and not na(s.tpLiq) ? math.abs(s.tpLiq - s.entry) / s.r : na

// ═══════════════════════════════════════════════════════════════════ DASHBOARD
posOf(string p) =>
    p == "Top Left" ? position.top_left : p == "Bottom Right" ? position.bottom_right : p == "Bottom Left" ? position.bottom_left : p == "Middle Right" ? position.middle_right : p == "Middle Left" ? position.middle_left : position.top_right
szLead() => inSize == "Small" ? size.small : inSize == "Large" ? size.large  : size.normal
szBody() => inSize == "Small" ? size.tiny  : inSize == "Large" ? size.normal : size.small
szSub()  => inSize == "Small" ? size.tiny  : inSize == "Large" ? size.small  : size.tiny
kv(table tb, int row, string k, string v, color vc, string tip) =>
    color stripe = inZebra and row % 2 == 1 ? color.new(th.accent, 93) : th.bg
    table.cell(tb, 0, row, k, text_color = th.flat, text_size = szBody(), text_halign = text.align_left, bgcolor = stripe, tooltip = tip)
    table.cell(tb, 1, row, v, text_color = vc, text_size = szBody(), text_halign = text.align_right, text_font_family = font.family_monospace, bgcolor = stripe, tooltip = tip)

// Read-only admission diagnostics. They never replace an active plan's state or frozen context.
sdViewAdmissionData() =>
    string status = ""
    if inCfPda and not cfHtfAllowed
        status := "SETTINGS: HTF FVG must exceed chart"
    else if inCfPda and not cfSourceReady
        status := "DATA WAIT: completed HTF source missing"
    if inCfSmt and not cfPair.ready
        status += (status == "" ? "" : "\n") + "SMT: waiting for aligned closed bars"
    status == "" ? "Data ready; setup gates still apply" : status
bool sdViewDataWait = (inCfPda and not cfSourceReady) or (inCfSmt and not cfPair.ready)
string sdViewDataHelp = "New-setup eligibility only. Set the required HTF FVG source strictly above the chart; missing completed source data blocks admission. Enabled SMT requires contiguous aligned closed-bar history. Existing pending/active plans retain their frozen context and lifecycle. Data ready does not mean a trade signal."

// At most 18 populated rows including admission status, within the 20-row allocation.
var table dash = table.new(posOf(inDashPos), 2, 20, bgcolor = th.bg, frame_color = th.frame, frame_width = 1, border_width = 0)
if barstate.islast and inDash
    table.clear(dash, 0, 0, 1, 19)
    int r = 0
    table.cell(dash, 0, r, "SWEEP DESK", text_color = th.accentHi, text_size = szLead(), text_halign = text.align_left, bgcolor = th.bg, tooltip = "Liquidity → shift → FVG, with a close-committed entry / stop / target engine. Every step commits on the closed bar.")
    table.cell(dash, 1, r, syminfo.ticker + " · " + timeframe.period, text_color = th.accent, text_size = szSub(), text_halign = text.align_right, bgcolor = th.bg)
    r += 1
    table.cell(dash, 0, r, (s.state >= 3 ? (s.dir == 1 ? "▲ " : "▼ ") : "● ") + stateWord, text_color = stateColor, text_size = szLead(), text_halign = text.align_left, bgcolor = th.bg, tooltip = "SCANNING = no raid yet · SWEPT = a pool was raided, waiting for the structure shift · SHIFTED = shift confirmed, looking for the FVG · ARMED = levels set, waiting for the fill · FILLED = trade open.")
    table.cell(dash, 1, r, s.state == 1 ? str.tostring(bar_index - s.sweepBar) + "/" + str.tostring(inShiftWin) + " bars" : s.state == 3 ? str.tostring(bar_index - s.armBar) + "/" + str.tostring(inExpire) + " bars" : s.state == 4 ? str.tostring(bar_index - s.fillBar) + " bars in" : clockTxt + " · " + kzName, text_color = th.txt, text_size = szBody(), text_halign = text.align_right, bgcolor = th.bg, tooltip = "Timers: bars since the sweep (shift window) · bars since armed (expiry) · bars in the trade.")
    r += 1
    kv(dash, r, "New setups · data", sdViewAdmissionData(), sdViewDataWait ? th.down : th.flat, sdViewDataHelp)
    r += 1
    kv(dash, r, "Pool", s.state > 0 ? (s.dir == 1 ? "SSL " : "BSL ") + s.poolKind + " " + fmt(s.poolPx) + " · " + str.tostring(s.sweepDepth, "0.00") + " ATR" : str.tostring(restingUp) + " BSL · " + str.tostring(restingDn) + " SSL resting", th.txt, "The raided pool: its kind (SWG swing · EQH/EQL equal highs/lows · SES killzone session · HTF higher-timeframe swing · PDH/PDL/PWH/PWL previous day/week) and how far the wick went through it in ATR. When scanning: how many pools are resting on each side.")
    r += 1
    // the level rows only exist while a setup is alive — an idle dashboard stays short
    if s.state > 0
        kv(dash, r, "Sweep", s.state > 0 ? (s.sweepKz ? "in killzone" : "outside killzones") + (inSweepVol ? " · vol ✓" : "") : "—", s.state > 0 ? (s.sweepKz ? th.up : th.txt) : th.flat, "Where the sweep happened (killzone or not) and whether the volume filter passed.")
        r += 1
        float qv = s.state > 0 ? nz(s.quality, 0.0) : na
        string qBar = na(qv) ? "—" : (qv >= 0.8 ? "▮▮▮▮▮" : qv >= 0.6 ? "▮▮▮▮▯" : qv >= 0.4 ? "▮▮▮▯▯" : qv >= 0.2 ? "▮▮▯▯▯" : "▮▯▯▯▯") + " " + str.tostring(math.round(qv * 100))
        color qBg = na(qv) ? th.bg : color.new(color.from_gradient(qv, 0.0, 1.0, th.down, th.up), 72)
        table.cell(dash, 0, r, "Sweep quality", text_color = th.flat, text_size = szBody(), text_halign = text.align_left, bgcolor = th.bg, tooltip = "Descriptive, frozen at the raid: depth of the wick through the pool, strength of the pool kind, killzone, volume. It never gates a setup and it is not a prediction.")
        table.cell(dash, 1, r, qBar, text_color = th.txtHi, text_size = szBody(), text_halign = text.align_right, text_font_family = font.family_monospace, bgcolor = qBg, tooltip = "Descriptive, frozen at the raid: depth of the wick through the pool, strength of the pool kind, killzone, volume. It never gates a setup and it is not a prediction.")
        r += 1
        string needTxt = inConfirm == "FVG inversion (iFVG)" ? "needs a close through an opposing gap" : inConfirm == "Structure shift (MSS)" ? "needs close " + (s.dir == 1 ? "above " : "below ") + (na(s.oppSwing) ? "—" : fmt(s.oppSwing)) : "close " + (s.dir == 1 ? "above " : "below ") + (na(s.oppSwing) ? "—" : fmt(s.oppSwing)) + " or through a gap"
        kv(dash, r, "Confirm", s.state >= 2 ? (s.model == "iFVG" ? "iFVG inverted" : "MSS " + fmt(s.mssPx)) + " · " + str.tostring(math.abs(close - s.ext) / atrU, "0.0") + " ATR leg" : s.state == 1 ? needTxt : "—", s.state >= 2 ? th.txtHi : th.txt, "How the reversal was confirmed: iFVG = a close through an opposing gap near the raid (the gap inverts) · MSS = a close beyond the last short-term swing with displacement. Plus the leg from the sweep extreme in ATR.")
        r += 1
        kv(dash, r, "Zone", s.state >= 3 ? fmt(s.fvgBot) + " – " + fmt(s.fvgTop) + (s.model == "iFVG" ? " · inverted gap" : " · leg FVG") : "—", th.txt, "The entry zone: the inverted gap (iFVG) or the imbalance inside the shift leg (MSS).")
        r += 1
        if s.state >= 3
            kv(dash, r, "Confluence · frozen", s.confluenceSummary, th.txt, "Observed at acceptance, before its first-touch token was consumed. A transparent checklist, not a probability or private grade.")
            r += 1
        kv(dash, r, "Entry", s.state >= 3 ? fmt(s.entry) + (s.state == 3 ? (inEntryMode == "Close inside" ? " · waiting close" : " · limit") : "") : "—", th.txtHi, "Confirmation close (default), later close inside, or resting edge/midpoint: see Entry setting. Paper reference; broker fills may differ.")
        r += 1
        kv(dash, r, "Stop", s.state >= 3 ? fmt(s.sl) + " · " + fmt(s.r) + " = 1R" + (s.tp2Hit ? " · TRAIL" : s.tp1Hit and inBE ? " · BE" : "") : "—", th.down, "Anchored to the sweep extreme (structural invalidation) plus a buffer — or the zone's far edge when the extreme is too far away (Auto) or when you chose it.")
        r += 1
        kv(dash, r, "R ladder", s.state >= 3 ? fmt(s.tp1) + " · " + fmt(s.tp2) + " · " + fmt(s.tp3) : "—", th.up, "Rounded R milestones. TP1 entry-stop move is optional; TP2 trails to TP1. Updates apply next bar, with no partial position exits.")
        r += 1
        kv(dash, r, "Liquidity", s.state >= 3 ? (na(s.tpLiq) ? "none ≥ " + str.tostring(inTpLiqMinR, "0.0") + "R resting" : fmt(s.tpLiq) + " · " + s.tpLiqKind + " · " + str.tostring(rrLiq, "0.0") + "R") : "—", na(rrLiq) ? th.flat : th.accentHi, "The nearest resting pool on the other side — where the market's next magnet sits. Final target = " + inPrimary + ".")
        r += 1
    kv(dash, r, "Swept · Confirm · Armed · Filled", str.tostring(st.swept) + " · " + str.tostring(st.shifted) + " · " + str.tostring(st.armed) + " · " + str.tostring(st.filled), th.txt, "How many raids were confirmed (iFVG or MSS), how many confirmations armed a zone, how many got filled — on the loaded history. The drop-offs are the filters doing their job.")
    r += 1
    int decided = st.n - st.scratch
    kv(dash, r, "Record", st.n < 5 ? str.tostring(st.n) + " closed · too few" : str.tostring(st.n) + " closed · " + str.tostring(math.round(st.wins / st.n * 100)) + "% win" + (st.scratch > 0 ? " · " + str.tostring(st.scratch) + " flat" : "") + " · avg " + (st.sumR / st.n >= 0 ? "+" : "") + str.tostring(st.sumR / st.n, "0.00") + "R", th.txt, "Closed trades on this chart: count · win% of ALL closed (flat = breakeven / gap scratches, counted as non-wins, same basis as the By-pool and By-model rows) · average R. Paper, no costs, small sample — a description of the past on THIS chart, not an edge.")
    r += 1
    string byPool = (st.nSwg > 0 ? "SWG " + str.tostring(math.round(st.wSwg / st.nSwg * 100)) + "%/" + str.tostring(st.nSwg) : "SWG —") + " · " + (st.nEq > 0 ? "EQ " + str.tostring(math.round(st.wEq / st.nEq * 100)) + "%/" + str.tostring(st.nEq) : "EQ —") + " · " + (st.nSes > 0 ? "SES " + str.tostring(math.round(st.wSes / st.nSes * 100)) + "%/" + str.tostring(st.nSes) : "SES —") + " · " + (st.nHtf > 0 ? "HTF " + str.tostring(math.round(st.wHtf / st.nHtf * 100)) + "%/" + str.tostring(st.nHtf) : "HTF —")
    kv(dash, r, "By pool", byPool, th.txt, "Win% / count by the kind of liquidity that was raided — swing, equal highs/lows, killzone session, or HTF (higher-timeframe swings + previous day / week). Which pools actually pay on YOUR chart.")
    r += 1
    string byModel = (st.nIfvg > 0 ? "iFVG " + str.tostring(math.round(st.wIfvg / st.nIfvg * 100)) + "%/" + str.tostring(st.nIfvg) : "iFVG —") + " · " + (st.nMss > 0 ? "MSS " + str.tostring(math.round(st.wMss / st.nMss * 100)) + "%/" + str.tostring(st.nMss) : "MSS —")
    kv(dash, r, "By model", byModel, th.txt, "Win% / count by confirmation model — the inversion (iFVG) entries versus the structure-shift (MSS) entries on this chart.")
    r += 1
    table.cell(dash, 0, r, (inConfirm == "Either" ? "iFVG+MSS" : inConfirm == "FVG inversion (iFVG)" ? "iFVG" : "MSS") + " · " + inEntryMode + " · " + (inSlMode == "Sweep extreme" ? "SL@sweep" : inSlMode == "Zone far edge" ? "SL@zone" : "SL@auto") + " · " + (inPrimary == "Liquidity" ? "TP@liq" : "TP@" + str.tostring(inTp3) + "R"), text_color = th.flat, text_size = szSub(), text_halign = text.align_left, bgcolor = th.bg, tooltip = "Entry mode · stop anchor · final target.")
    table.cell(dash, 1, r, (inSweepKz ? "killzones only" : "any time") + " · " + str.tostring(array.size(pools)) + " pools", text_color = th.flat, text_size = szSub(), text_halign = text.align_right, bgcolor = th.bg, tooltip = "Sweep time filter and how many pools (resting + recently swept) are tracked.")

// ═══════════════════════════════════════════════════════════════════ NARRATIVE · Co-Pilot (translates state; never a new signal)
wrap(string sIn, int width) =>
    array<string> words = str.split(sIn, " ")
    string out  = ""
    string line = ""
    for wd in words
        if line != "" and str.length(line) + str.length(wd) + 1 > width
            out  := out + (out == "" ? "" : "\n") + line
            line := wd
        else
            line := line == "" ? wd : line + " " + wd
    out == "" ? line : out + "\n" + line

narrNow() =>
    string o = ""
    if s.state == 4
        float finalR = (s.tpFinal - s.entry) * s.dir / s.r
        o := "paper " + (s.dir == 1 ? "long" : "short") + " from " + fmt(s.entry) + " — current stop " + fmt(s.sl) + (s.tp2Hit ? " (TP2 trail)" : s.tp1Hit and inBE ? " (entry stop)" : " (" + s.stopBasis + ")") + ", final " + fmt(s.tpFinal) + " (" + str.tostring(finalR, "0.00") + "R" + ((not na(s.tpLiq) and sdCompare(s.tpFinal, s.tpLiq) == 0) ? "; " + s.tpLiqKind : "; fixed ladder") + ")"
    else if s.state == 3
        o := (s.dir == 1 ? "long" : "short") + " plan armed — " + (inEntryMode == "Close inside" ? "waiting for a valid close inside " + fmt(s.fvgBot) + "–" + fmt(s.fvgTop) : "resting " + inEntryMode + " limit " + fmt(s.entry)) + "; pending stop " + fmt(s.sl0) + " (" + s.stopBasis + "); " + str.tostring(math.max(0, inExpire - (bar_index - s.armBar))) + " bars until unfilled expiry"
    else if s.state == 2
        o := "structure shifted " + (s.dir == 1 ? "up" : "down") + " after the raid — looking for the imbalance in the leg to arm the entry (" + str.tostring(inFvgWin - (bar_index - s.mssBar)) + " bars left)"
    else if s.state == 1
        o := (s.dir == 1 ? "sell-side" : "buy-side") + " liquidity at " + fmt(s.poolPx) + " (" + s.poolKind + ") was raided and rejected — waiting for " + (inConfirm == "FVG inversion (iFVG)" ? "a close through an opposing gap (inversion)" : inConfirm == "Structure shift (MSS)" ? "a close " + (s.dir == 1 ? "above " : "below ") + (na(s.oppSwing) ? "the last short-term swing" : fmt(s.oppSwing)) : "a close through an opposing gap or " + (s.dir == 1 ? "above " : "below ") + (na(s.oppSwing) ? "the last short-term swing" : fmt(s.oppSwing))) + " with displacement, " + str.tostring(inShiftWin - (bar_index - s.sweepBar)) + " bars left"
    else
        o := "scanning — " + str.tostring(restingUp) + " buy-side and " + str.tostring(restingDn) + " sell-side pools are resting; nothing has been raided yet"
    o
narrWhy() =>
    string o = "The sequence is mechanical: a pool is swept (wick through by ≥ " + str.tostring(inSweepMin, "0.00") + " ATR, close back inside), then the reversal is confirmed " + (inConfirm == "FVG inversion (iFVG)" ? "when a bar closes through an opposing gap — the gap inverts and becomes the zone" : inConfirm == "Structure shift (MSS)" ? "when a bar closes beyond the last short-term swing — the gap in that leg becomes the zone" : "by a close through an opposing gap (the gap inverts) or beyond the last short-term swing (the gap in that leg)") + ", always with a leg ≥ " + str.tostring(inDispAtr, "0.0") + " ATR from the extreme. "
    o := o + (s.state > 0 ? "This one: " + s.poolKind + " pool, wick " + str.tostring(s.sweepDepth, "0.00") + " ATR through it, " + (s.sweepKz ? "inside a killzone" : "outside the killzones") + "." : "Killzone now: " + kzName + ".")
    o
narrPlan() =>
    string o = ""
    if s.state >= 3
        float finalR = (s.tpFinal - s.entry) * s.dir / s.r
        string targetBasis = (not na(s.tpLiq) and sdCompare(s.tpFinal, s.tpLiq) == 0) ? "nearest qualifying " + s.tpLiqKind : inPrimary == "Liquidity" and na(s.tpLiq) ? "explicit TP3 fallback" : "selected TP3 rung"
        o := "Entry " + fmt(s.entry) + "; initial stop " + fmt(s.sl0) + " (" + s.stopBasis + "), current stop " + fmt(s.sl) + ". TP1 " + fmt(s.tp1) + (inBE ? " moves the stop toward entry" : " is a checkpoint; entry-stop move is off") + "; TP2 " + fmt(s.tp2) + " moves it toward TP1. These stop changes apply from the next bar. Final " + fmt(s.tpFinal) + " = " + str.tostring(finalR, "0.00") + "R (" + targetBasis + "). Known open gaps resolve first; stop-first only for unordered range hits. Closed-bar entries exclude all earlier entry-bar wicks."
    else if s.state == 1
        o := (inConfirm == "FVG inversion (iFVG)" ? "If a bar closes through an opposing gap near the raid with displacement, that gap inverts and the entry is armed on it. " : inConfirm == "Structure shift (MSS)" ? "If a bar closes " + (s.dir == 1 ? "above " : "below ") + (na(s.oppSwing) ? "the last short-term swing" : fmt(s.oppSwing)) + " with displacement, the shift is in. " : "A close through an opposing gap (inversion) arms the entry on that gap; a close " + (s.dir == 1 ? "above " : "below ") + (na(s.oppSwing) ? "the last short-term swing" : fmt(s.oppSwing)) + " arms it on the gap in that leg. ") + "If price closes back through the raided pool by " + str.tostring(inDispAtr, "0.0") + " ATR, the raid was not a raid."
    o
narrTrack() =>
    int decided = st.n - st.scratch
    "Funnel here: " + str.tostring(st.swept) + " sweeps → " + str.tostring(st.shifted) + " confirmed → " + str.tostring(st.armed) + " armed → " + str.tostring(st.filled) + " filled. " + (st.n < 5 ? "Too few closed trades to say anything yet." : str.tostring(st.n) + " closed, " + str.tostring(math.round(st.wins / st.n * 100)) + "% won" + (st.scratch > 0 ? " (" + str.tostring(st.scratch) + " flat)" : "") + ", avg " + (st.sumR / st.n >= 0 ? "+" : "") + str.tostring(st.sumR / st.n, "0.00") + "R (paper, no costs, small sample — NOT an edge).")
narrPosOf(string p) =>
    p == "Top Left" ? position.top_left : p == "Top Right" ? position.top_right : p == "Bottom Right" ? position.bottom_right : p == "Middle Left" ? position.middle_left : p == "Middle Right" ? position.middle_right : position.bottom_left
narrBodySz() => inNarrSize == "Small" ? size.small : inNarrSize == "Medium" ? size.normal : inNarrSize == "Large" ? size.large : szBody()
narrHeadSz() => inNarrSize == "Small" ? size.normal : inNarrSize == "Medium" ? size.large : inNarrSize == "Large" ? size.large : szLead()

var table narrT = na
if barstate.islast
    if not na(narrT)
        table.delete(narrT)
        narrT := na
    if inNarr != "Off"
        array<string> labs = array.new<string>()
        array<string> vals = array.new<string>()
        array.push(labs, "NOW")
        array.push(vals, wrap(narrNow() + " (decision support, not a trade instruction)", inNarrWrap))
        bool deep = inNarr == "Standard" or inNarr == "Detailed"
        if deep
            array.push(labs, "WHY")
            array.push(vals, wrap(narrWhy(), inNarrWrap))
            string p = narrPlan()
            if p != ""
                array.push(labs, "PLAN")
                array.push(vals, wrap(p, inNarrWrap))
        if inNarr == "Detailed"
            array.push(labs, "TRACK REC")
            array.push(vals, wrap(narrTrack(), inNarrWrap))
            array.push(labs, "GLOSSARY")
            array.push(vals, wrap("Pool = resting liquidity (stops) at a swing, equal highs/lows, a session or a day/week extreme. Sweep = a wick through it that closes back. iFVG = an opposing gap that price closes through, so it inverts and becomes the zone. MSS = a close beyond the last short-term swing the other way; then the gap in that leg is the zone. The selected stop anchor is sweep extreme, zone far edge, or Auto; the accepted ticket shows its actual basis.", inNarrWrap))
        int nr = array.size(labs)
        bool atBottom = inNarrPos == "Bottom Left" or inNarrPos == "Bottom Right"
        int rows = nr + 2 + (atBottom ? 1 : 0)
        narrT := table.new(narrPosOf(inNarrPos), 2, rows, bgcolor = th.bg, frame_color = th.frame, frame_width = 1, border_color = color.new(th.frame, 70), border_width = 1)
        table.cell(narrT, 0, 0, "◆ CO-PILOT", text_color = th.accentHi, bgcolor = color.new(th.accent, 88), text_size = narrHeadSz(), text_halign = text.align_left)
        table.cell(narrT, 1, 0, stateWord + " · " + str.upper(inNarr), text_color = th.accent, bgcolor = color.new(th.accent, 88), text_size = narrBodySz(), text_halign = text.align_right)
        for i = 0 to nr - 1
            table.cell(narrT, 0, i + 1, array.get(labs, i), text_color = th.accent, bgcolor = th.bg, text_size = narrBodySz(), text_halign = text.align_left, text_valign = text.align_top)
            table.cell(narrT, 1, i + 1, array.get(vals, i), text_color = th.txt, bgcolor = th.bg, text_size = narrBodySz(), text_halign = text.align_left, text_valign = text.align_top)
        table.cell(narrT, 0, nr + 1, "", bgcolor = th.bg)
        table.cell(narrT, 1, nr + 1, "Honest: levels are geometry, not promises. Paper ledger has no costs; the strategy twin measures it with costs.", text_color = th.flat, bgcolor = th.bg, text_size = narrBodySz(), text_halign = text.align_left)
        if atBottom
            table.cell(narrT, 0, nr + 2, " ", bgcolor = th.bg)
            table.cell(narrT, 1, nr + 2, " ", bgcolor = th.bg)

// ═══════════════════════════════════════════════════════════════════ ALERTS (confirmed bars only by construction)
payload(string evName, string why) =>
    string js = '{"src":"SweepDesk","symbol":"' + sdEscape(syminfo.tickerid) + '","tf":"' + sdEscape(timeframe.period) + '","event":"' + evName + '","side":"' + (s.dir == 1 or ev.dir == 1 ? "LONG" : "SHORT") + '","model":"' + sdEscape(s.model) + '","pool":"' + sdEscape(s.poolKind) + '","entry":' + sdNumber(s.entry) + ',"sl":' + sdNumber(s.sl) + ',"initial_sl":' + sdNumber(s.sl0) + ',"tp1":' + sdNumber(s.tp1) + ',"tp2":' + sdNumber(s.tp2) + ',"tp3":' + sdNumber(s.tp3) + ',"tp_liq":' + sdNumber(s.tpLiq) + ',"target":' + sdNumber(s.tpFinal) + ',"reason":"' + sdEscape(why) + '","confluences":"' + sdEscape(s.confluenceSummary) + '","entry_time":' + sdNumber(s.entryTime) + ',"end_observed_time":' + sdNumber(s.endTime) + ',"exit_price":' + sdNumber(ev.exit ? ev.px : na) + ',"gross_r":' + sdNumber(ev.r) + '}'
    string tx = "Sweep Desk · " + syminfo.ticker + " " + timeframe.period + " · " + evName + " · " + (s.dir == 1 or ev.dir == 1 ? "LONG" : "SHORT") + " · entry " + fmt(nz(s.entry, ev.px)) + " · SL " + fmt(nz(s.sl, 0)) + " · TP " + fmt(nz(s.tp1, 0)) + " / " + fmt(nz(s.tp2, 0)) + " / " + fmt(nz(s.tp3, 0)) + (na(s.tpLiq) ? "" : " · liq " + fmt(s.tpLiq)) + (why != "" ? " · " + why : "")
    inAlertJson ? js : tx
alertcondition(ev.armed and s.dir == 1,  "Long setup armed",  "Sweep Desk: LONG plan armed — inspect entry mode and frozen structural levels")
alertcondition(ev.armed and s.dir == -1, "Short setup armed", "Sweep Desk: SHORT plan armed — inspect entry mode and frozen structural levels")
alertcondition(ev.filled and ev.dir == 1,  "Long filled",  "Sweep Desk: LONG filled")
alertcondition(ev.filled and ev.dir == -1, "Short filled", "Sweep Desk: SHORT filled")
alertcondition(ev.tp1, "TP1 reached", "Sweep Desk: TP1 reached — stop moves per settings")
alertcondition(ev.tp2, "TP2 reached", "Sweep Desk: TP2 reached — stop to TP1")
alertcondition(ev.exit and (ev.why == "TP-LIQ" or ev.why == "TP3"), "Final target reached", "Sweep Desk: final target reached")
alertcondition(ev.exit and (str.contains(ev.why, "SL") or str.contains(ev.why, "BE") or str.contains(ev.why, "TRAIL") or ev.why == "GAP STOP"), "Stopped", "Sweep Desk: stop hit")
alertcondition(ev.cancel, "Setup cancelled", "Sweep Desk: setup cancelled")
alertcondition(ev.sweep, "Liquidity swept", "Sweep Desk: a pool was raided — watching for the confirmation")
alertcondition(ev.shift and s.model == "iFVG", "FVG inverted (iFVG)", "Sweep Desk: an opposing gap inverted after the raid — zone armed")
alertcondition(ev.sweep and (s.poolKind == "HTF" or s.poolKind == "PDH" or s.poolKind == "PDL" or s.poolKind == "PWH" or s.poolKind == "PWL"), "Higher-timeframe pool swept", "Sweep Desk: a higher-timeframe pool (HTF swing · previous day · previous week) was raided")
// One envelope per confirmed update; no same-bar FILLED/EXIT or TP1/TP2 event is lost.
if barstate.isconfirmed and (ev.armed or ev.filled or ev.tp1 or ev.tp2 or ev.exit or ev.cancel)
    string kinds = ""
    if ev.armed
        kinds += "\"ARMED\""
    if ev.filled
        kinds += (kinds == "" ? "" : ",") + "\"FILLED\""
    if ev.tp1
        kinds += (kinds == "" ? "" : ",") + "\"TP1\""
    if ev.tp2
        kinds += (kinds == "" ? "" : ",") + "\"TP2\""
    if ev.exit
        kinds += (kinds == "" ? "" : ",") + "\"EXIT\""
    if ev.cancel
        kinds += (kinds == "" ? "" : ",") + "\"CANCEL\""
    string primary = ev.exit ? "EXIT" : ev.cancel ? "CANCEL" : ev.filled ? "FILLED" : ev.armed ? "ARMED" : ev.tp2 ? "TP2" : "TP1"
    string message = payload(primary, ev.why)
    if inAlertJson
        message := str.substring(message, 0, str.length(message) - 1) + ",\"schema\":\"sweep-desk.v2\",\"events\":[" + kinds + "],\"confirmed\":true,\"fill_confirmed\":false,\"costs_included\":false,\"bar_close_ms\":" + str.tostring(time_close) + ",\"plan_id\":" + (na(s.planId) ? "null" : str.tostring(s.planId)) + ",\"ambiguous\":" + (s.ambiguous ? "true" : "false") + "}"
    alert(message, alert.freq_once_per_bar_close)
Download the indicator .pine file

The strategy twin · optional

Measure the rules with costs on a second chart.

Strategy code

Copy the code as it is. No coding needed.

View the full code
// ═══════════════════════════════════════════════════════════════════════════════
//  SWEEP DESK — Liquidity · Shift · FVG  ·  Pine Script v6  ·  v2.0.0
//  The liquidity-sweep model made mechanical and honest, with closed-bar structural references and explicit paper execution assumptions.
//
//  THE SEQUENCE (every step commits on the CLOSED bar):
//    1 · POOLS   resting liquidity = confirmed swing highs/lows, equal highs/lows, killzone-session
//                highs/lows, previous day / week highs/lows. Each pool is resting until it is swept.
//    2 · SWEEP   a bar wicks THROUGH a pool by ≥ X ATR and CLOSES back inside → the pool is raided.
//    3 · CONFIRM within N bars, either (a) iFVG — a bar CLOSES THROUGH an opposing imbalance printed near the
//                raid, so that gap INVERTS and becomes the entry zone (an original, explicitly defined inversion model), or (b) MSS — a
//                close beyond the last opposing short-term swing with displacement (leg from the extreme ≥ k ATR).
//    4 · ZONE    iFVG: the inverted gap · MSS: the 3-candle imbalance printed inside the shift leg.
//    5 · ENTRY   confirmation close (v2 default); optional later close inside or resting edge / midpoint limit.
//    6 · STOP    the sweep extreme ± a buffer — the structural invalidation, not a guess.
//    7 · TARGET  frozen 1R / 2R / 3R milestones; optional nearest qualifying fresh opposing pool; explicit TP3 fallback.
//
//  WHY IT EXISTS  "Finding the liquidity is easy; the entries and exits are where the gold is." This
//                 build answers that with a state machine you can read on the dashboard, stop-first
//                 accounting, and a track record that counts every setup it ever armed.
//  STATUS         v2.0.0 · reliability revision · verification scope in docs/build-report.md · paper
//                 ledger has NO costs · the Strategy Lab measures independent broker fills with costs · not a validated edge.
//
//  License: MIT — share freely, keep this header.  © 2026 ProEA Lab · built for r/pinescript
// ═══════════════════════════════════════════════════════════════════════════════
//@version=6
strategy("Sweep Desk — Strategy Lab", "SweepDeskS", overlay = true, behind_chart = false, max_bars_back = 3000,
     initial_capital = 100000, currency = currency.USD, default_qty_type = strategy.fixed, default_qty_value = 1, pyramiding = 0,
     commission_type = strategy.commission.percent, commission_value = 0.02, slippage = 5,
     margin_long = 100, margin_short = 100, process_orders_on_close = true,
     calc_on_every_tick = false, calc_on_order_fills = false, calc_on_every_history_tick = false,
     max_lines_count = 250, max_labels_count = 200, max_boxes_count = 120)
// GENERATED v2.0.0: shared technical events remain paper-paced; broker occupancy/fills/exits are independent.
// Generic costs: 0.02% per order + 5 ticks/fill, 100% margin. Default risk budget USD100 and 90% equity notional cap.
// Explicit USD account conversion at submission; actual gap/slippage risk can exceed the signal-close estimate.
// Use Strategy Tester for broker results. The dashboard is the separate, cost-free paper ledger.
// Keep the declared recalculation settings; intrabar/fill recalculation is unsupported.

// ─────────────────────────────────────────────────────────────────── INPUTS · liquidity pools
gP = "Liquidity pools"
inPivLen   = input.int(5, "Swing pivot length", minval = 2, maxval = 30, group = gP,
     tooltip = "Bars on each side that confirm a swing high / low. A swing becomes a resting pool N bars after it forms — no repainting, some lag.", display = display.none)
inEqTol    = input.float(0.15, "Equal highs / lows tolerance (× ATR)", minval = 0.0, maxval = 1.0, step = 0.05, group = gP,
     tooltip = "A new swing within this distance of a resting pool merges into it as an EQUAL high / low (EQH / EQL) — the pools the market loves to raid.", display = display.none)
inPoolSes  = input.bool(true, "Killzone session highs / lows", group = gP, tooltip = "When a killzone ends, its high and low become resting pools.", display = display.none)
inPoolPD   = input.bool(true, "Previous day high / low", group = gP, display = display.none)
inPoolPW   = input.bool(true, "Previous week high / low", group = gP, display = display.none)
inMaxPools = input.int(10, "Max resting pools per side", minval = 3, maxval = 30, group = gP, tooltip = "Oldest swing pools are dropped beyond this (day / week pools are protected; session pools follow the age limit).", display = display.none)
inPoolAge  = input.int(400, "Drop a resting swing / session pool after N bars", minval = 50, maxval = 3000, group = gP, display = display.none)
inHtfOn    = input.bool(true, "Higher-timeframe swing pools", group = gP, tooltip = "Confirmed swing highs / lows from a higher timeframe rest as pools too (kind HTF). Read from the last CLOSED higher-timeframe bar — no lookahead.", display = display.none)
inHtf      = input.timeframe("60", "  Higher timeframe", group = gP, display = display.none)
inHtfLen   = input.int(5, "  HTF pivot length", minval = 2, maxval = 20, group = gP, display = display.none)
inPoolNear = input.float(8.0, "Draw pools within N ATR of price", minval = 1.0, maxval = 50.0, step = 0.5, group = gP, tooltip = "Display only — every pool is still tracked and can be a target. Keeps the chart focused on the liquidity that matters now.", display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · sweep filters
gS = "Sweep filters (better sweeps, fewer fakes)"
inSweepMin  = input.float(0.05, "Wick beyond the pool ≥ ATR ×", minval = 0.0, maxval = 2.0, step = 0.05, group = gS,
     tooltip = "A touch is not a sweep. The wick must go through the pool by at least this much ATR and the bar must CLOSE back on the pool's side.", display = display.none)
inSweepVol  = input.bool(false, "Require a volume spike on the sweep bar", group = gS, display = display.none)
inSweepVolK = input.float(1.3, "Volume ≥ SMA(20) ×", minval = 1.0, maxval = 5.0, step = 0.1, group = gS, display = display.none)
inSweepKz   = input.bool(false, "Only sweeps inside a killzone", group = gS, tooltip = "Off = any time. On = the sweep bar must fall inside one of the killzones below.", display = display.none)
inPoolMin   = input.string("Any pool", "A setup needs a sweep of", options = ["Any pool", "Strong pools only"], group = gS,
     tooltip = "Strong pools = equal highs/lows, killzone session extremes, higher-timeframe swings, previous day / week levels. Plain swing pools are still tracked and drawn, but sweeping one does not start a setup.", display = display.none)
inDir       = input.string("Both", "Direction", options = ["Both", "Long only", "Short only"], group = gS, display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · shift + FVG
gM = "Confirmation (iFVG / MSS) + zone"
inConfirm   = input.string("FVG inversion (iFVG)", "Confirmation", options = ["FVG inversion (iFVG)", "Structure shift (MSS)", "Either"], group = gM,
     tooltip = "How the reversal is confirmed after the raid. iFVG = a bar CLOSES through an opposing imbalance printed near the raid — that gap inverts and becomes the entry zone (the inversion model). MSS = a close beyond the last short-term swing with displacement, then the imbalance in that leg. Either = whichever prints first.", display = display.none)
inIfvgBack  = input.int(40, "iFVG: the gap may have formed up to N bars before the sweep", minval = 5, maxval = 200, group = gM, display = display.none)
inShiftLeg  = input.int(3, "Short-term swing length (the swing the shift must break)", minval = 2, maxval = 10, group = gM, display = display.none)
inShiftWin  = input.int(12, "Confirmation must happen within N bars of the sweep", minval = 3, maxval = 60, group = gM, display = display.none)
inDispAtr   = input.float(1.0, "Displacement: leg from the sweep extreme ≥ ATR ×", minval = 0.2, maxval = 5.0, step = 0.1, group = gM,
     tooltip = "The confirming bar's close must be at least this far (in ATR) from the sweep extreme — a real push, not a drift. Applies to both models.", display = display.none)
inFvgMin    = input.float(0.10, "FVG size ≥ ATR ×", minval = 0.0, maxval = 2.0, step = 0.05, group = gM, display = display.none)
inFvgPick   = input.string("Nearest", "Which FVG in the leg", options = ["Nearest", "Deepest"], group = gM,
     tooltip = "Nearest = the last imbalance printed in the shift leg (price reaches it first). Deepest = the first one (bigger discount, fills less often).", display = display.none)
inFvgWin    = input.int(8, "MSS: the FVG must exist within N bars after the shift", minval = 1, maxval = 30, group = gM, display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · entry / stop / targets
gE = "Entry · stop · targets"
inEntryMode = input.string("Confirmation close", "Entry", options = ["Confirmation close", "FVG edge", "CE (50%)", "Close inside"], group = gE,
     tooltip = "Confirmation close = enter at the confirmed inversion / MSS-zone acceptance close. This is the v2 factory choice. Close entries cannot exit on earlier wicks. FVG edge = limit at the near edge of the gap. CE = limit at the gap's midpoint (consequent encroachment). Close inside = wait for a bar to close inside the gap, enter at that close.", display = display.none)
inSlMode    = input.string("Sweep extreme", "Stop anchor", options = ["Auto", "Sweep extreme", "Zone far edge"], group = gE,
     tooltip = "Sweep extreme = the structural invalidation: if price trades back through the raided low / high, the idea is wrong. Zone far edge = the other side of the entry zone — tighter, more stop-outs. Auto = the sweep extreme unless it sits more than N ATR from the entry (a stop that far is no longer a structural stop for this timeframe), then the zone far edge.", display = display.none)
inSlMaxAtr  = input.float(2.5, "Auto: sweep extreme allowed up to N ATR from the entry", minval = 0.5, maxval = 10.0, step = 0.5, group = gE, display = display.none)
inSlBuf     = input.float(0.10, "Stop buffer (× ATR)", minval = 0.0, maxval = 1.0, step = 0.05, group = gE, display = display.none)
inTp1       = input.float(1.0, "TP1 (R)", minval = 0.3, maxval = 10, step = 0.1, group = gE, display = display.none)
inTp2       = input.float(2.0, "TP2 (R)", minval = 0.5, maxval = 20, step = 0.1, group = gE, display = display.none)
inTp3       = input.float(3.0, "TP3 (R)", minval = 0.5, maxval = 30, step = 0.1, group = gE, display = display.none)
inTpLiqMinR = input.float(2.0, "Liquidity target must be ≥ R", minval = 0.3, maxval = 10, step = 0.1, group = gE,
     tooltip = "The opposing-liquidity target is the nearest resting pool on the other side that is at least this many R away.", display = display.none)
inPrimary   = input.string("R ladder (TP3)", "Final target", options = ["Liquidity", "R ladder (TP3)"], group = gE,
     tooltip = "Which target closes the trade. Liquidity = the opposing pool (falls back to TP3 when none qualifies). TP1 / TP2 are milestones either way.", display = display.none)
inBE        = input.bool(true, "TP1 → stop to breakeven", group = gE, display = display.none)
inExpire    = input.int(30, "Unfilled setup expires after N bars", minval = 3, maxval = 300, group = gE, display = display.none)
inMaxTrade  = input.int(200, "Open trade times out after N bars", minval = 10, maxval = 2000, group = gE, display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · killzones
gK = "Killzones (exchange time)"
inTZ  = input.string("America/New_York", "Timezone", group = gK, tooltip = "IANA name. ICT killzones are defined in New York time.", display = display.none)
inKz1 = input.session("0200-0500", "London", group = gK, display = display.none)
inKz2 = input.session("0830-1100", "New York AM", group = gK, display = display.none)
inKz3 = input.session("1330-1600", "New York PM", group = gK, display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · style / panels / alerts
gV = "Style"
inView   = input.string("Minimal", "View preset", options = ["Minimal", "Clean", "Pro", "Full"], group = gV,
     tooltip = "Minimal = the active setup only. Clean = + resting pools + setup FVG + MSS. Pro = + every FVG, sweep tags, pool labels. Full = + killzone shading.", display = display.none)
inTheme  = input.string("Aurora Neon", "Theme", options = ["Aurora Neon", "Royal Gold", "Colorblind-safe"], group = gV, display = display.none)
inZones  = input.bool(true, "Gradient risk / reward zones", group = gV, display = display.none)
inTint   = input.bool(false, "Tint candles while a trade is open", group = gV, display = display.none)
inGlow   = input.bool(true, "Glow on strong pools (EQ · session · HTF · day / week)", group = gV, display = display.none)
inExtend = input.int(20, "Pool label offset (bars)", minval = 5, maxval = 200, group = gV, display = display.none)
gD = "Dashboard"
inDash    = input.bool(false, "Show dashboard", group = gD, display = display.none)
inDashPos = input.string("Top Right", "Position", options = ["Top Right", "Top Left", "Bottom Right", "Bottom Left", "Middle Right", "Middle Left"], group = gD, display = display.none)
inSize    = input.string("Medium", "Size", options = ["Small", "Medium", "Large"], group = gD, display = display.none)
inZebra   = input.bool(true, "Row stripes", group = gD, display = display.none)
gN = "Narrative · Co-Pilot"
inNarr     = input.string("Off", "Narrative depth", options = ["Off", "Brief", "Standard", "Detailed"], group = gN, display = display.none)
inNarrPos  = input.string("Bottom Left", "Position", options = ["Bottom Left", "Bottom Right", "Top Left", "Top Right", "Middle Left", "Middle Right"], group = gN, display = display.none)
inNarrSize = input.string("Auto", "Size", options = ["Auto", "Small", "Medium", "Large"], group = gN, display = display.none)
inNarrWrap = input.int(64, "Line width (characters)", minval = 30, maxval = 120, group = gN, display = display.none)
gA = "Alerts"
inAlertJson = input.bool(true, "alert() payload as JSON (webhooks)", group = gA, tooltip = "Choose 'Any alert() function call' when creating the alert.", display = display.none)

// v2 additions follow all existing controls to preserve prior input identities.
gV2 = "v2 · transparent confirmation / target rules"
inGapMode = input.string("Single", "iFVG mode", options = ["Single", "Series"], group = gV2, display = display.none, tooltip = "Single = individual three-candle gap. Series = a union of same-direction gaps born on consecutive chart bars, before inversion; the whole union must be crossed by a later close. This precise adjacency rule is original, not a claim of private parity.")
inMacro = input.string("Off", "Confirmation macro window", options = ["Off", "45–15", "50–10", "US open 30m"], group = gV2, display = display.none, tooltip = "Evaluated at the confirmation candle CLOSE in the selected timezone. 45–15: minute 45 through 14; 50–10: minute 50 through 09; US open 30m: 09:30 inclusive to 10:00 exclusive. No exchange holiday calendar.")
inTargetQuality = input.string("Any fresh pool", "Opposing target quality", options = ["Any fresh pool", "Clustered (2+ touches)"], group = gV2, display = display.none, tooltip = "Clustered requires two separately confirmed chart swing observations merged into one still-fresh EQ pool. This is a transparent level cluster, not a trendline detector. The minimum-R rule and explicit TP3 fallback still apply.")

// ─────────────────────────────────────────────────────────────────── THEME
type Theme
    color bg       = #0B0E14
    color frame    = #B8923A
    color accent   = #D8B14A
    color accentHi = #E8C25A
    color up       = #2BE7C7
    color down     = #FF3B5C
    color flat     = #6B7280
    color txt      = #D7DCE6
    color txtHi    = #ECEFF5
themeOf(string name) =>
    Theme t = Theme.new()
    if name == "Aurora Neon"
        t.bg       := #0E1118
        t.frame    := #2C3A5A
        t.accent   := #7C9CFF
        t.accentHi := #A9B8FF
        t.up       := #2AF0C8
        t.down     := #FF4D6D
        t.flat     := #5B6472
        t.txt      := #E6E9EF
        t.txtHi    := #F2F4F9
    else if name == "Colorblind-safe"
        t.bg       := #0E1014
        t.frame    := #3A4150
        t.accent   := #56B4E9
        t.accentHi := #8ED0F5
        t.up       := #E69F00
        t.down     := #0072B2
        t.flat     := #8C92A0
        t.txt      := #E8EAED
        t.txtHi    := #FFFFFF
    t
Theme th = themeOf(inTheme)
bool vClean = inView != "Minimal"
bool vPro   = inView == "Pro" or inView == "Full"
bool vFull  = inView == "Full"
fmt(float x) => str.tostring(x, format.mintick)
clamp01(float x) => math.max(0.0, math.min(1.0, x))

// ── PURE RELIABILITY HELPERS BEGIN · shared by literal Pine fixtures and broker adapter
sdSign(float x) =>
    na(x) ? 0 : int(nz(x / math.abs(x), 0))
sdCompare(float lhs, float rhs) =>
    float tolerance = 4 * 2.220446049250313e-16 * math.max(math.abs(lhs), math.abs(rhs))
    sdSign(math.abs(lhs - rhs) - tolerance) <= 0 ? 0 : sdSign(lhs - rhs)
sdBarValid(float o, float h, float l, float c) =>
    not na(o) and not na(h) and not na(l) and not na(c) and sdSign(h - l) >= 0 and sdSign(o - l) >= 0 and sdSign(h - o) >= 0 and sdSign(c - l) >= 0 and sdSign(h - c) >= 0
sdTickOut(float px, int roundingDirection, float tick) =>
    float raw = px / tick
    bool supported = not na(raw) and math.abs(raw) <= 9e18
    float nearest = supported ? math.round(raw) : na
    // Never absorb a genuine fraction of a tick at a large absolute coordinate.
    float tolerance = math.min(1e-5, 8 * 2.220446049250313e-16 * math.max(1, math.abs(raw)))
    float normalized = sdSign(math.abs(raw - nearest) - tolerance) <= 0 ? nearest : raw
    supported ? (roundingDirection == 1 ? math.ceil(normalized) : math.floor(normalized)) * tick : float(na)
sdDistance(float fromPrice, float toPrice, int direction, float tick) =>
    float raw = (toPrice - fromPrice) * direction
    float ticks = raw / tick
    float nearest = math.round(ticks)
    float tolerance = math.min(tick * 1e-5, 8 * 2.220446049250313e-16 * math.max(math.abs(fromPrice), math.abs(toPrice)))
    sdSign(math.abs(raw - nearest * tick) - tolerance) <= 0 ? nearest * tick : raw
sdPoolObservation(int side, float level, float h, float l, float c, float minimumDepth) =>
    bool traversed = side == 1 ? sdCompare(h, level) > 0 : sdCompare(l, level) < 0
    bool raid = traversed and (side == 1 ? sdCompare(h, level + minimumDepth) >= 0 and sdCompare(c, level) < 0 : sdCompare(l, level - minimumDepth) <= 0 and sdCompare(c, level) > 0)
    [traversed, raid]
sdPlanGeometry(int direction, float entry, float anchor, float atrValue, float tick, float buffer, float r1, float r2, float r3) =>
    bool available = not na(entry) and not na(anchor) and not na(atrValue) and sdSign(atrValue) > 0 and sdSign(tick) > 0
    bool ordered = r1 > 0 and sdCompare(r2, r1) > 0 and sdCompare(r3, r2) > 0
    float rawStop = anchor - direction * buffer * atrValue
    float minimumStop = entry - direction * tick
    float sl = sdTickOut(direction == 1 ? math.min(rawStop, minimumStop) : math.max(rawStop, minimumStop), -direction, tick)
    float risk = sdDistance(sl, entry, direction, tick)
    float t1 = sdTickOut(entry + direction * r1 * risk, direction, tick)
    float t2 = sdTickOut(entry + direction * r2 * risk, direction, tick)
    float t3 = sdTickOut(entry + direction * r3 * risk, direction, tick)
    bool valid = available and ordered and sdCompare(risk, tick) >= 0 and sdCompare((t1 - entry) * direction, 0) > 0 and sdCompare((t2 - t1) * direction, 0) > 0 and sdCompare((t3 - t2) * direction, 0) > 0
    string reason = not available ? "Unavailable price / ATR / tick" : not ordered ? "Require 0 < TP1 < TP2 < TP3" : not valid ? "Rounded plan geometry invalid" : "Plan valid"
    [sl, risk, t1, t2, t3, valid, reason]
sdOutcome(int direction, float stop, float target, float o, float h, float l, float c, int entryBar, int currentBar, int maximumHold) =>
    bool ended = false
    float price = na
    string why = ""
    bool ambiguous = false
    if currentBar > entryBar and sdBarValid(o, h, l, c)
        bool stopGap = sdCompare(o, stop) * direction <= 0
        bool targetGap = sdCompare(o, target) * direction >= 0
        bool stopTouch = direction == 1 ? sdCompare(l, stop) <= 0 : sdCompare(h, stop) >= 0
        bool targetTouch = direction == 1 ? sdCompare(h, target) >= 0 : sdCompare(l, target) <= 0
        if stopGap
            price := o
            why := "GAP STOP"
        else if targetGap
            price := target
            why := "TARGET"
        else if stopTouch
            price := stop
            why := "STOP"
            ambiguous := targetTouch
        else if targetTouch
            price := target
            why := "TARGET"
        else if currentBar - entryBar >= maximumHold
            price := c
            why := "TIME"
        ended := not na(price)
    [ended, price, why, ambiguous]
sdSeriesJoin(bool enabled, int direction, int previousDirection, int birth, int previousBirth, bool previousInverted) =>
    enabled and not previousInverted and direction == previousDirection and birth == previousBirth + 1
sdInversion(int direction, float closedPrice, float top, float bottom) =>
    direction == 1 ? sdCompare(closedPrice, bottom) < 0 : sdCompare(closedPrice, top) > 0
sdEscape(string value) =>
    str.replace_all(str.replace_all(str.replace_all(str.replace_all(value, "\\", "\\\\"), "\"", "\\\""), "\n", "\\n"), "\t", "\\t")
sdNumber(float value) =>
    string result = "null"
    if not na(value)
        if sdSign(value) == 0
            result := "0"
        else
            int exponent = int(math.floor(math.log10(math.abs(value))))
            result := exponent < -6 or exponent > 12 ? str.tostring(value / math.pow(10, exponent), "0.################") + "e" + str.tostring(exponent) : str.tostring(value, "0.################")
    result
sdMacroWindow(string mode, int localHour, int localMinute) =>
    mode == "Off" or (mode == "45–15" ? localMinute >= 45 or localMinute < 15 : mode == "50–10" ? localMinute >= 50 or localMinute < 10 : localHour == 9 and localMinute >= 30)
sdMilestones(int direction, float entry, float stop, float tp1, float tp2, float h, float l, bool previousOne, bool previousTwo, bool moveBreakeven, float tick) =>
    bool one = previousOne or (direction == 1 ? sdCompare(h, tp1) >= 0 : sdCompare(l, tp1) <= 0)
    bool two = previousTwo or (direction == 1 ? sdCompare(h, tp2) >= 0 : sdCompare(l, tp2) <= 0)
    float nextStop = stop
    if one and not previousOne and moveBreakeven
        float be = sdTickOut(entry, -direction, tick)
        nextStop := direction == 1 ? math.max(nextStop, be) : math.min(nextStop, be)
    if two and not previousTwo
        nextStop := direction == 1 ? math.max(nextStop, tp1) : math.min(nextStop, tp1)
    [nextStop, one, two, one and not previousOne, two and not previousTwo]
sdQuantity(bool fixedMode, float fixedUnits, float cashBudget, float unitRisk, float unitNotional, float equity, float capPercent, float increment) =>
    bool available = not na(unitRisk) and not na(unitNotional) and not na(equity) and not na(increment) and sdSign(unitRisk) > 0 and sdSign(unitNotional) > 0 and sdSign(equity) > 0 and sdSign(increment) > 0 and sdSign(capPercent) > 0 and (fixedMode ? sdSign(fixedUnits) > 0 : sdSign(cashBudget) > 0)
    float requested = fixedMode ? fixedUnits : cashBudget / unitRisk
    float capUnits = equity * capPercent / 100 / unitNotional
    float steps = available ? math.min(requested, capUnits) / increment : 0
    bool supported = steps <= 9e18
    float nearest = supported ? math.round(steps) : 0
    float tolerance = math.min(1e-5, 4 * 2.220446049250313e-16 * math.max(1, math.abs(steps)))
    float normalized = sdSign(math.abs(steps - nearest) - tolerance) <= 0 ? nearest : steps
    float quantity = available and supported ? math.floor(normalized) * increment : 0
    float actualRisk = quantity * unitRisk
    float notional = quantity * unitNotional
    bool withinBudget = (fixedMode or sdCompare(actualRisk, cashBudget) <= 0) and sdCompare(notional, equity * capPercent / 100) <= 0
    bool valid = available and supported and sdSign(quantity) > 0 and withinBudget
    string reason = not available ? "Price / account conversion / sizing unavailable" : not supported ? "Quantity needs more than supported integer steps" : sdSign(quantity) <= 0 ? "No size fits risk / equity cap at minimum increment" : not withinBudget ? "Sizing arithmetic exceeded budget" : "Sizing valid"
    [valid ? quantity : 0.0, valid ? actualRisk : float(na), valid ? notional : float(na), valid, reason]
sdPendingCloseDecision(int direction, float closedPrice, float zoneTop, float zoneBottom, float frozenStop) =>
    bool inside = sdCompare(closedPrice, zoneBottom) >= 0 and sdCompare(closedPrice, zoneTop) <= 0
    bool invalid = sdCompare(closedPrice, frozenStop) * direction < 0
    [inside, invalid, inside and not invalid]
sdGapValid(float top, float bottom, float minimumSize) =>
    not na(top) and not na(bottom) and not na(minimumSize) and sdCompare(top, bottom) > 0 and sdCompare(top, bottom + minimumSize) >= 0
sdDisplacement(int direction, float closedPrice, float extreme, float minimumDistance) =>
    not na(closedPrice) and not na(extreme) and not na(minimumDistance) and sdCompare(closedPrice, extreme + direction * minimumDistance) * direction >= 0
// ── PURE RELIABILITY HELPERS END

if barstate.isfirst
    if not chart.is_standard or not timeframe.isintraday
        runtime.error("Sweep Desk requires standard time-based intraday candles.")
    if not (inTp1 > 0 and inTp2 > inTp1 and inTp3 > inTp2)
        runtime.error("Targets must satisfy 0 < TP1 < TP2 < TP3.")
if barstate.isconfirmed and not sdBarValid(open, high, low, close)
    runtime.error("Sweep Desk: missing or malformed confirmed OHLC. No reliable price transition can be calculated.")

// ═══════════════════════════════════════════════════════════════════ ENGINE INPUTS (ta.* at global scope)
float atr   = ta.atr(14)
float atrU  = math.max(atr, syminfo.mintick)
float ph    = ta.pivothigh(high, inPivLen, inPivLen)
float pl    = ta.pivotlow(low, inPivLen, inPivLen)
float phS   = ta.pivothigh(high, inShiftLeg, inShiftLeg)
float plS   = ta.pivotlow(low, inShiftLeg, inShiftLeg)
float volMa = ta.sma(nz(volume, 0.0), 20)
bool  volOK = not inSweepVol or (not na(volume) and sdSign(volume) > 0 and not na(volMa) and sdSign(volMa) > 0 and sdCompare(volume, inSweepVolK * volMa) >= 0)
bool  kz1   = not na(time(timeframe.period, inKz1, inTZ))
bool  kz2   = not na(time(timeframe.period, inKz2, inTZ))
bool  kz3   = not na(time(timeframe.period, inKz3, inTZ))
bool  inKz  = kz1 or kz2 or kz3
string kzName = kz1 ? "London" : kz2 ? "NY AM" : kz3 ? "NY PM" : "outside killzones"
// previous day / week highs and lows — the [1] + lookahead_on pair = the last CLOSED period (never the forming one)
[pdStamp, pdh, pdl] = request.security(syminfo.tickerid, "D", [time[1], high[1], low[1]], lookahead = barmerge.lookahead_on)
[pwStamp, pwh, pwl] = request.security(syminfo.tickerid, "W", [time[1], high[1], low[1]], lookahead = barmerge.lookahead_on)
bool  dayOK  = timeframe.in_seconds() < timeframe.in_seconds("D")
bool  weekOK = timeframe.in_seconds() < timeframe.in_seconds("W")
htfPhFn() => ta.pivothigh(high, inHtfLen, inHtfLen)
htfPlFn() => ta.pivotlow(low, inHtfLen, inHtfLen)
float htfPh  = request.security(syminfo.tickerid, inHtf, htfPhFn()[1], lookahead = barmerge.lookahead_on)
float htfPl  = request.security(syminfo.tickerid, inHtf, htfPlFn()[1], lookahead = barmerge.lookahead_on)
bool  htfOK  = timeframe.in_seconds(inHtf) > timeframe.in_seconds()
int   htfMult = math.max(1, math.round(timeframe.in_seconds(inHtf) / timeframe.in_seconds()))

// CONFLUENCE FRAGMENT BEGIN
// Public confluence concepts, original deterministic definitions. No private DD grade or LRL formula.
gCF = "Confluence · transparent rules"
inCfPda = input.bool(true, "Require first-touch HTF FVG delivery", group = gCF, display = display.none,
     tooltip = "A previously untouched, completed higher-timeframe 3-candle gap must be touched first. Its delivery context lasts N chart bars and can admit only one plan. This is our explicit PDA definition, not a private vendor formula.")
inCfHtf = input.timeframe("60", "  HTF FVG source", group = gCF, display = display.none)
inCfPdaAge = input.int(6, "  First-touch context lasts chart bars", minval = 1, maxval = 30, group = gCF, display = display.none)
inCfPdaLife = input.int(50, "  Untouched gap lifetime · HTF periods", minval = 2, maxval = 200, group = gCF, display = display.none)
inCfSmt = input.bool(false, "Require paired-market SMT", group = gCF, display = display.none,
     tooltip = "Select a meaningful comparison yourself. Uses strictly aligned, previously CLOSED chart and comparison bars. A missing bar resets the comparison window. Context is deliberately one chart bar late; no automatic correlation claim.")
inCfPair = input.symbol("BINANCE:BTCUSDT", "  SMT comparison symbol", group = gCF, display = display.none)
inCfInverse = input.bool(false, "  Inverse relationship", group = gCF, display = display.none)
inCfWindow = input.int(10, "  SMT aligned-bar reference window", minval = 3, maxval = 100, group = gCF, display = display.none)
inCfSmtAge = input.int(6, "  SMT context lasts chart bars", minval = 1, maxval = 30, group = gCF, display = display.none)
inCfMomentum = input.bool(false, "Require directional momentum candle", group = gCF, display = display.none)
inCfBody = input.float(0.5, "  Body size ≥ ATR ×", minval = 0.0, maxval = 3.0, step = 0.1, group = gCF, display = display.none)
inCfLocation = input.float(0.7, "  Directional close location ≥", minval = 0.5, maxval = 1.0, step = 0.05, group = gCF, display = display.none)
inCfVotes = input.int(0, "Minimum observed confluences · 0–4", minval = 0, maxval = 4, group = gCF, display = display.none,
     tooltip = "One vote each: available first-touch HTF FVG, enabled aligned SMT, directional momentum, positive volume ≥ 1.3× its 20-bar mean. This is a transparent checklist, not an A+ grade, probability or optimized threshold. SMT supplies no vote while disabled.")

cfValid(float h, float l) => not na(h) and not na(l) and sdCompare(h, l) >= 0
cfOverlap(float h, float l, float top, float bot) => cfValid(h, l) and cfValid(top, bot) and sdCompare(l, top) <= 0 and sdCompare(h, bot) >= 0
cfPdaAction(int dir, float top, float bot, float h, float l, float c) =>
    int action = 0
    if not cfValid(h, l) or na(c) or sdCompare(c, h) > 0 or sdCompare(c, l) < 0 or not cfValid(top, bot) or sdCompare(top, bot) <= 0
        action := -1
    else if (dir == 1 and sdCompare(c, bot) < 0) or (dir == -1 and sdCompare(c, top) > 0)
        action := -1
    else if cfOverlap(h, l, top, bot)
        action := 1
    action

cfMomentum(int dir, float o, float h, float l, float c, float a, float minBody, float minLocation) =>
    bool valid = cfValid(h, l) and not na(o) and not na(c) and sdCompare(o, l) >= 0 and sdCompare(o, h) <= 0 and sdCompare(c, l) >= 0 and sdCompare(c, h) <= 0 and sdCompare(h, l) > 0 and not na(a) and sdSign(a) > 0
    valid and sdSign((c - o) * dir) > 0 and sdCompare(math.abs(c - o), minBody * a) >= 0 and sdCompare(dir == 1 ? (c - l) / (h - l) : (h - c) / (h - l), minLocation) >= 0

type CfPair
    array<float> ownH
    array<float> ownL
    array<float> peerH
    array<float> peerL
    int stamp = na
    int bullAt = na
    int bearAt = na
    bool ready = false

cfPairReset(CfPair p) =>
    array.clear(p.ownH)
    array.clear(p.ownL)
    array.clear(p.peerH)
    array.clear(p.peerL)
    p.stamp := na
    p.bullAt := na
    p.bearAt := na
    p.ready := false

cfPairStep(CfPair p, int sourceTime, int peerTime, int sourceEnd, int peerEnd, int interval, float ownH, float ownL, float peerH, float peerL, int window, bool inverse, int observedBar) =>
    bool valid = interval > 0 and not na(sourceTime) and sourceTime == peerTime and sourceEnd == peerEnd and sourceEnd - sourceTime == interval and cfValid(ownH, ownL) and cfValid(peerH, peerL)
    if not valid
        cfPairReset(p)
    else if na(p.stamp) or sourceTime != p.stamp
        if not na(p.stamp) and sourceTime != p.stamp + interval
            cfPairReset(p)
        p.ready := array.size(p.ownH) >= window
        if p.ready
            bool ownNewLow = sdCompare(ownL, array.min(p.ownL)) < 0
            bool ownNewHigh = sdCompare(ownH, array.max(p.ownH)) > 0
            bool peerNoLow = sdCompare(peerL, array.min(p.peerL)) >= 0
            bool peerNoHigh = sdCompare(peerH, array.max(p.peerH)) <= 0
            if ownNewLow and (inverse ? peerNoHigh : peerNoLow)
                p.bullAt := observedBar
            if ownNewHigh and (inverse ? peerNoLow : peerNoHigh)
                p.bearAt := observedBar
        array.push(p.ownH, ownH)
        array.push(p.ownL, ownL)
        array.push(p.peerH, peerH)
        array.push(p.peerL, peerL)
        while array.size(p.ownH) > window
            array.shift(p.ownH)
            array.shift(p.ownL)
            array.shift(p.peerH)
            array.shift(p.peerL)
        p.stamp := sourceTime
    p.ready

type CfPda
    float top
    float bot
    int dir
    int formedAt
    bool fresh = true

type CfDelivery
    int bullAt = na
    int bearAt = na
    int bullSource = na
    int bearSource = na
    float bullTop = na
    float bullBot = na
    float bearTop = na
    float bearBot = na
    bool bullUsed = false
    bool bearUsed = false

cfDeliveryObserve(CfDelivery d, CfPda z, float h, float l, float c, int at) =>
    int action = cfPdaAction(z.dir, z.top, z.bot, h, l, c)
    if z.fresh and action != 0
        z.fresh := false
        if action == 1
            if z.dir == 1
                d.bullAt := at
                d.bullSource := z.formedAt
                d.bullTop := z.top
                d.bullBot := z.bot
                d.bullUsed := false
            else
                d.bearAt := at
                d.bearSource := z.formedAt
                d.bearTop := z.top
                d.bearBot := z.bot
                d.bearUsed := false

cfDeliveryReady(CfDelivery d, int dir, int at, int ttl) =>
    int touched = dir == 1 ? d.bullAt : d.bearAt
    bool used = dir == 1 ? d.bullUsed : d.bearUsed
    not used and not na(touched) and at >= touched and at - touched < ttl

cfDeliveryConsume(CfDelivery d, int dir) =>
    if dir == 1
        d.bullUsed := true
    else
        d.bearUsed := true

var array<CfPda> cfPdas = array.new<CfPda>()
var CfDelivery cfDelivery = CfDelivery.new()
var CfPair cfPair = CfPair.new(array.new<float>(), array.new<float>(), array.new<float>(), array.new<float>())
var int cfLastHtf = na
float cfChartSeconds = timeframe.in_seconds()
float cfHtfSeconds = timeframe.in_seconds(inCfHtf)
bool cfHtfAllowed = not na(cfChartSeconds) and not na(cfHtfSeconds) and cfHtfSeconds > cfChartSeconds
[cfH1, cfL1, cfH3, cfL3, cfHtfTime, cfHtfEnd] = request.security(syminfo.tickerid, inCfHtf, [high[1], low[1], high[3], low[3], time[1], time_close[1]], lookahead = barmerge.lookahead_on)
[cfPeerH, cfPeerL, cfPeerTime, cfPeerEnd] = request.security(inCfPair, timeframe.period, [high[1], low[1], time[1], time_close[1]], gaps = barmerge.gaps_on, lookahead = barmerge.lookahead_on, ignore_invalid_symbol = true)
bool cfSourceReady = cfHtfAllowed and cfValid(cfH1, cfL1) and cfValid(cfH3, cfL3) and not na(cfHtfEnd) and cfHtfEnd <= time_close
bool cfMomentumLong = cfMomentum(1, open, high, low, close, atr, inCfBody, inCfLocation)
bool cfMomentumShort = cfMomentum(-1, open, high, low, close, atr, inCfBody, inCfLocation)
bool cfVolume = not na(volume) and sdSign(volume) > 0 and not na(volMa) and sdSign(volMa) > 0 and sdCompare(volume, 1.3 * volMa) >= 0

if barstate.isconfirmed
    if inCfSmt
        cfPairStep(cfPair, time[1], cfPeerTime, time_close[1], cfPeerEnd, int(cfChartSeconds * 1000), high[1], low[1], cfPeerH, cfPeerL, inCfWindow, inCfInverse, bar_index)
    else
        cfPairReset(cfPair)
    if not cfSourceReady
        array.clear(cfPdas)
        cfDelivery.bullAt := na
        cfDelivery.bearAt := na
    else
        if na(cfLastHtf) or cfHtfTime != cfLastHtf
            // A chart bar straddling formation cannot establish post-formation freshness: skip that birth.
            if cfHtfEnd <= time
                if sdCompare(cfL1, cfH3) > 0
                    array.push(cfPdas, CfPda.new(cfL1, cfH3, 1, cfHtfEnd))
                if sdCompare(cfH1, cfL3) < 0
                    array.push(cfPdas, CfPda.new(cfL3, cfH1, -1, cfHtfEnd))
            cfLastHtf := cfHtfTime
        // Oldest-to-newest: if several fresh zones are touched together, the most recently formed wins.
        for z in cfPdas
            bool expired = time_close - z.formedAt > cfHtfSeconds * 1000 * inCfPdaLife
            if expired
                z.fresh := false
            else
                cfDeliveryObserve(cfDelivery, z, high, low, close, bar_index)
        if not na(cfDelivery.bullBot) and sdCompare(close, cfDelivery.bullBot) < 0
            cfDelivery.bullAt := na
        if not na(cfDelivery.bearTop) and sdCompare(close, cfDelivery.bearTop) > 0
            cfDelivery.bearAt := na
        while array.size(cfPdas) > 40
            array.shift(cfPdas)

sdConfluencePda(int dir) => cfSourceReady and cfDeliveryReady(cfDelivery, dir, bar_index, inCfPdaAge)
sdConfluenceSmt(int dir) =>
    int at = dir == 1 ? cfPair.bullAt : cfPair.bearAt
    inCfSmt and cfPair.ready and not na(at) and bar_index >= at and bar_index - at < inCfSmtAge
sdConfluenceMomentum(int dir) => dir == 1 ? cfMomentumLong : cfMomentumShort
sdConfluenceVotes(int dir) => (sdConfluencePda(dir) ? 1 : 0) + (sdConfluenceSmt(dir) ? 1 : 0) + (sdConfluenceMomentum(dir) ? 1 : 0) + (cfVolume ? 1 : 0)
sdConfluenceAllowed(int dir) => (not inCfPda or sdConfluencePda(dir)) and (not inCfSmt or sdConfluenceSmt(dir)) and (not inCfMomentum or sdConfluenceMomentum(dir)) and sdConfluenceVotes(dir) >= inCfVotes
sdConfluenceReason(int dir) =>
    string reason = "confluence ready"
    if inCfPda and not cfSourceReady
        reason := cfHtfAllowed ? "HTF FVG source unavailable" : "HTF FVG timeframe must be higher than chart"
    else if inCfPda and not sdConfluencePda(dir)
        reason := "no unused first-touch HTF FVG delivery"
    else if inCfSmt and not cfPair.ready
        reason := "SMT waiting for aligned closed-bar history"
    else if inCfSmt and not sdConfluenceSmt(dir)
        reason := "no recent paired-market SMT"
    else if inCfMomentum and not sdConfluenceMomentum(dir)
        reason := "confirmation body lacks directional momentum"
    else if sdConfluenceVotes(dir) < inCfVotes
        reason := "observed confluence count below minimum"
    reason
sdConfluenceSummary(int dir) => "PDA " + (sdConfluencePda(dir) ? "first touch" : "unavailable/used") + " · SMT " + (not inCfSmt ? "off" : sdConfluenceSmt(dir) ? "confirmed -1 bar" : "waiting") + " · " + str.tostring(sdConfluenceVotes(dir)) + "/4 observed"
sdConfluenceConsume(int dir) =>
    if sdConfluencePda(dir)
        cfDeliveryConsume(cfDelivery, dir)
// CONFLUENCE FRAGMENT END
bool sdMacroAllowed = sdMacroWindow(inMacro, hour(time_close, inTZ), minute(time_close, inTZ))
sdAdmissionAllowed(int direction) => sdMacroAllowed and sdConfluenceAllowed(direction)
sdAdmissionReason(int direction) => not sdMacroAllowed ? "Confirmation outside selected macro" : sdConfluenceReason(direction)

// ═══════════════════════════════════════════════════════════════════ POOLS (resting liquidity)
type Pool
    float  px
    int    bar
    int    side        // +1 = buy-side liquidity (a high) · −1 = sell-side liquidity (a low)
    string kind        // SWG · EQH · EQL · SES · PDH · PDL · PWH · PWL
    int    touches = 1
    bool   swept   = false
    bool   faded   = false
    int    sweptBar = na
    line   ln
    line   ln2      // glow (strong pools only)
    label  lb
var array<Pool> pools = array.new<Pool>()

poolColor(int side, bool swept) =>
    color base = side == 1 ? th.down : th.up
    swept ? color.new(base, 78) : color.new(base, 35)

poolDelete(Pool p) =>
    if not na(p.ln)
        line.delete(p.ln)
    if not na(p.ln2)
        line.delete(p.ln2)
    if not na(p.lb)
        label.delete(p.lb)

// register (or merge into an equal pool); draw only outside Minimal
poolAdd(float px, int bar, int side, string kind) =>
    bool merged = false
    if kind == "SWG"
        for p in pools
            if not p.swept and (p.kind == "SWG" or p.kind == "EQH" or p.kind == "EQL") and p.side == side and sdCompare(px, p.px - inEqTol * atrU) >= 0 and sdCompare(px, p.px + inEqTol * atrU) <= 0
                p.px := side == 1 ? math.max(p.px, px) : math.min(p.px, px)
                p.kind := side == 1 ? "EQH" : "EQL"
                p.touches := p.touches + 1
                p.bar := math.min(p.bar, bar)
                merged := true
                break
    if not merged
        // one PDH / PDL / PWH / PWL at a time — replace the previous period's pool
        if (kind == "PDH" or kind == "PDL" or kind == "PWH" or kind == "PWL") and array.size(pools) > 0
            for i = array.size(pools) - 1 to 0
                Pool q = array.get(pools, i)
                if q.kind == kind
                    poolDelete(q)
                    array.remove(pools, i)
        Pool p = Pool.new(px = px, bar = bar, side = side, kind = kind)
        array.push(pools, p)
    merged

// Budget caps swing pools; swing and session pools age out. Swept pools linger forty bars.
poolPrune() =>
    int up = 0
    int dn = 0
    for i = array.size(pools) - 1 to 0
        if i < 0
            break
        Pool p = array.get(pools, i)
        bool isSwing = p.kind == "SWG" or p.kind == "EQH" or p.kind == "EQL" or p.kind == "HTF"
        bool old = p.swept ? (bar_index - nz(p.sweptBar, bar_index)) > 40 : ((isSwing or p.kind == "SES") and (bar_index - p.bar) > inPoolAge)
        if old
            poolDelete(p)
            array.remove(pools, i)
        else if not p.swept and isSwing
            if p.side == 1
                up += 1
            else
                dn += 1
    // over budget → drop the oldest resting swing pools
    int i = 0
    while i < array.size(pools)
        int sizeBefore = array.size(pools)
        Pool p = array.get(pools, i)
        bool isSwing = p.kind == "SWG" or p.kind == "EQH" or p.kind == "EQL" or p.kind == "HTF"
        if not p.swept and isSwing
            if p.side == 1 and up > inMaxPools
                poolDelete(p)
                array.remove(pools, i)
                up -= 1
            else if p.side == -1 and dn > inMaxPools
                poolDelete(p)
                array.remove(pools, i)
                dn -= 1
        if array.size(pools) == sizeBefore
            i += 1

// killzone session pools: when a killzone ends, its high / low rest as liquidity
var float k1H = na
var float k1L = na
var float k2H = na
var float k2L = na
var float k3H = na
var float k3L = na
if kz1
    k1H := kz1[1] ? math.max(nz(k1H, high), high) : high
    k1L := kz1[1] ? math.min(nz(k1L, low), low) : low
if kz2
    k2H := kz2[1] ? math.max(nz(k2H, high), high) : high
    k2L := kz2[1] ? math.min(nz(k2L, low), low) : low
if kz3
    k3H := kz3[1] ? math.max(nz(k3H, high), high) : high
    k3L := kz3[1] ? math.min(nz(k3L, low), low) : low

if barstate.isconfirmed and not na(atr)
    if not na(ph)
        poolAdd(ph, bar_index - inPivLen, 1, "SWG")
    if not na(pl)
        poolAdd(pl, bar_index - inPivLen, -1, "SWG")
    if inPoolSes
        if kz1[1] and not kz1 and not na(k1H)
            poolAdd(k1H, bar_index - 1, 1, "SES")
            poolAdd(k1L, bar_index - 1, -1, "SES")
        if kz2[1] and not kz2 and not na(k2H)
            poolAdd(k2H, bar_index - 1, 1, "SES")
            poolAdd(k2L, bar_index - 1, -1, "SES")
        if kz3[1] and not kz3 and not na(k3H)
            poolAdd(k3H, bar_index - 1, 1, "SES")
            poolAdd(k3L, bar_index - 1, -1, "SES")
    if inPoolPD and dayOK and not na(pdStamp) and (na(pdStamp[1]) or pdStamp != pdStamp[1])
        poolAdd(pdh, bar_index, 1, "PDH")
        poolAdd(pdl, bar_index, -1, "PDL")
    if inPoolPW and weekOK and not na(pwStamp) and (na(pwStamp[1]) or pwStamp != pwStamp[1])
        poolAdd(pwh, bar_index, 1, "PWH")
        poolAdd(pwl, bar_index, -1, "PWL")
    if inHtfOn and htfOK
        if not na(htfPh) and (na(htfPh[1]) or htfPh != htfPh[1])
            poolAdd(htfPh, bar_index - inHtfLen * htfMult, 1, "HTF")
        if not na(htfPl) and (na(htfPl[1]) or htfPl != htfPl[1])
            poolAdd(htfPl, bar_index - inHtfLen * htfMult, -1, "HTF")
    poolPrune()

// ═══════════════════════════════════════════════════════════════════ SHORT-TERM SWINGS (the swing a shift must break)
var float stHi    = na
var int   stHiBar = na
var float stLo    = na
var int   stLoBar = na
if not na(phS)
    stHi    := phS
    stHiBar := bar_index - inShiftLeg
if not na(plS)
    stLo    := plS
    stLoBar := bar_index - inShiftLeg

// ═══════════════════════════════════════════════════════════════════ FVG REGISTRY (for the inversion model)
// every 3-candle gap ≥ the minimum size is tracked until a bar CLOSES through it the wrong way (inversion) or it ages out
type Gap
    float top
    float bot
    int   dir            // +1 bullish gap · −1 bearish gap
    int   bar
    bool  inverted = false
    int   invBar   = na
    int   lastBar  = na
    int   members  = 1
var array<Gap> gaps = array.new<Gap>()
gapRegister(float top, float bot, int direction, int birth) =>
    bool joined = false
    if inGapMode == "Series" and array.size(gaps) > 0
        Gap previous = array.get(gaps, array.size(gaps) - 1)
        if sdSeriesJoin(true, direction, previous.dir, birth, previous.lastBar, previous.inverted)
            previous.top := math.max(previous.top, top)
            previous.bot := math.min(previous.bot, bot)
            previous.lastBar := birth
            previous.members += 1
            joined := true
    if not joined
        array.push(gaps, Gap.new(top = top, bot = bot, dir = direction, bar = birth, lastBar = birth))
if barstate.isconfirmed and not na(atr)
    if sdGapValid(low, high[2], inFvgMin * atrU)
        gapRegister(low, high[2], 1, bar_index - 1)
    if sdGapValid(low[2], high, inFvgMin * atrU)
        gapRegister(low[2], high, -1, bar_index - 1)
    for i = array.size(gaps) - 1 to 0
        if i < 0
            break
        Gap g = array.get(gaps, i)
        if bar_index - g.bar > 200
            array.remove(gaps, i)
        else if not g.inverted
            if sdInversion(g.dir, close, g.top, g.bot)
                g.inverted := true
                g.invBar := bar_index
    while array.size(gaps) > 60
        array.shift(gaps)

// sweep quality 0..1 — DESCRIPTIVE (depth of the raid, strength of the pool, killzone, volume). Never gates anything.
poolWeight(string kind) => kind == "SWG" ? 0.45 : (kind == "EQH" or kind == "EQL") ? 0.8 : kind == "SES" ? 0.75 : 1.0
sweepQuality(float depthAtr, string kind, bool kz) =>
    float volTerm = volMa > 0 ? math.min(nz(volume, 0.0) / volMa / 2.0, 1.0) : 0.5
    clamp01(0.35 * math.min(depthAtr / 0.5, 1.0) + 0.35 * poolWeight(kind) + 0.15 * (kz ? 1.0 : 0.0) + 0.15 * volTerm)

// ═══════════════════════════════════════════════════════════════════ THE SETUP STATE MACHINE
// states: 0 SCANNING · 1 SWEPT · 2 SHIFTED · 3 ARMED (waiting for the fill) · 4 FILLED (trade open)
type Setup
    int    dir      = 0
    int    state    = 0
    float  poolPx   = na
    string poolKind = ""
    float  ext      = na        // the sweep extreme — structural invalidation
    int    sweepBar = na
    bool   sweepKz  = false
    float  sweepDepth = na
    float  oppSwing = na
    int    oppBar   = na
    int    mssBar   = na
    float  mssPx    = na
    float  fvgTop   = na
    float  fvgBot   = na
    int    fvgBar   = na
    float  entry    = na
    float  sl       = na
    float  sl0      = na
    float  r        = na
    float  tp1      = na
    float  tp2      = na
    float  tp3      = na
    float  tpLiq    = na
    string tpLiqKind = ""
    float  tpFinal  = na
    int    armBar   = na
    int    fillBar  = na
    bool   tp1Hit   = false
    bool   tp2Hit   = false
    string model    = ""        // "iFVG" or "MSS"
    float  quality  = na        // sweep quality 0..1 (descriptive, frozen at the raid)
    int    armTime = na
    int    entryTime = na
    int    endTime = na
    int    planId = na
    bool   planValid = false
    string planReason = "No plan"
    bool   ambiguous = false
    string confluenceSummary = ""
    string stopBasis = ""
type Stat
    int   swept    = 0
    int   shifted  = 0
    int   armed    = 0
    int   filled   = 0
    int   n        = 0
    int   wins     = 0
    int   scratch  = 0
    float sumR     = 0.0
    int   ambiguous = 0
    int   nSwg = 0
    int   wSwg = 0
    int   nEq  = 0
    int   wEq  = 0
    int   nSes = 0
    int   wSes = 0
    int   nHtf = 0
    int   wHtf = 0
    int   nIfvg = 0
    int   wIfvg = 0
    int   nMss  = 0
    int   wMss  = 0
var Setup s  = Setup.new()
var Stat  st = Stat.new()

// Frozen completed paper episodes; never aliases the mutable current Setup.
type SdPlanRecord
    int planId
    int direction
    string model
    string poolKind
    float poolPrice
    float zoneHigh
    float zoneLow
    float entry
    float initialStop
    float target
    float risk
    int armTime
    int entryTime
    int endTime
    float exitPrice
    float grossR
    string outcome
    bool ambiguous
    string confluences
var array<SdPlanRecord> sdHistory = array.new<SdPlanRecord>()
var int sdLastClosedTime = na
var float sdLastClosedPrice = na
if barstate.isconfirmed
    sdLastClosedTime := time_close
    sdLastClosedPrice := close

// per-bar event record (a fresh object every bar; functions mutate its fields — globals cannot be rebound in functions)
type Ev
    bool   sweep  = false
    bool   shift  = false
    bool   armed  = false
    bool   filled = false
    bool   exit   = false
    bool   cancel = false
    bool   tp1    = false
    bool   tp2    = false
    string why    = ""
    float  r      = na
    float  px     = na
    int    dir    = 0
Ev ev = Ev.new()

// nearest resting pool on the opposite side, at least minR × r away → the liquidity target
liqTarget(int dir, float entry, float r) =>
    float best = na
    string bk = ""
    for p in pools
        if not p.swept and (inTargetQuality == "Any fresh pool" or p.touches >= 2)
            float targetPx = sdTickOut(p.px, -dir, syminfo.mintick)
            if dir == 1 and p.side == 1 and sdCompare(targetPx, entry + inTpLiqMinR * r) >= 0
                if na(best) or sdCompare(targetPx, best) < 0
                    best := targetPx
                    bk := p.kind
            if dir == -1 and p.side == -1 and sdCompare(targetPx, entry - inTpLiqMinR * r) <= 0
                if na(best) or sdCompare(targetPx, best) > 0
                    best := targetPx
                    bk := p.kind
    [best, bk]

// FVG search inside the shift leg (offsets 0..len): bull = low[i] > high[i+2]
findFvg(int dir, int len) =>
    float top = na
    float bot = na
    int   at  = na
    int   lim = math.min(math.max(len, 0), 60)
    for i = 0 to lim
        bool bull = dir == 1 and sdGapValid(low[i], high[i + 2], inFvgMin * atrU)
        bool bear = dir == -1 and sdGapValid(low[i + 2], high[i], inFvgMin * atrU)
        if bull or bear
            float t = bull ? low[i] : low[i + 2]
            float b = bull ? high[i + 2] : high[i]
            if sdGapValid(t, b, inFvgMin * atrU)
                if inFvgPick == "Nearest"
                    top := t
                    bot := b
                    at  := bar_index - i
                    break
                else
                    top := t
                    bot := b
                    at  := bar_index - i
    [top, bot, at]

setLevels(int dir, float entry, bool checkAdmission = true) =>
    float zoneFar = dir == 1 ? s.fvgBot : s.fvgTop
    bool extNear = sdCompare(entry, s.ext - inSlMaxAtr * atrU) >= 0 and sdCompare(entry, s.ext + inSlMaxAtr * atrU) <= 0
    s.stopBasis := inSlMode == "Sweep extreme" ? "sweep extreme" : inSlMode == "Zone far edge" ? "zone far edge" : extNear ? "Auto: sweep extreme" : "Auto: zone far edge"
    float anchor = inSlMode == "Sweep extreme" ? s.ext : inSlMode == "Zone far edge" ? zoneFar : (extNear ? s.ext : zoneFar)
    [slPx, risk, t1, t2, t3, valid, reason] = sdPlanGeometry(dir, entry, anchor, atrU, syminfo.mintick, inSlBuf, inTp1, inTp2, inTp3)
    s.entry := entry
    s.sl := slPx
    s.sl0 := slPx
    s.r := risk
    s.tp1 := t1
    s.tp2 := t2
    s.tp3 := t3
    [lq, lk] = liqTarget(dir, entry, risk)
    s.tpLiq := lq
    s.tpLiqKind := lk
    s.tpFinal := inPrimary == "Liquidity" and not na(lq) ? lq : t3
    s.planValid := valid and (not checkAdmission or sdAdmissionAllowed(dir))
    s.planReason := checkAdmission and not sdAdmissionAllowed(dir) ? sdAdmissionReason(dir) : reason
    s.ambiguous := false
    valid

closeTrade(float px, string why) =>
    float rr = (px - s.entry) * s.dir / s.r
    ev.exit := true
    ev.why  := why
    ev.r    := rr
    ev.px   := px
    ev.dir  := s.dir
    s.endTime := time_close
    array.unshift(sdHistory, SdPlanRecord.new(s.planId, ev.dir, s.model, s.poolKind, s.poolPx, s.fvgTop, s.fvgBot, s.entry, s.sl0, s.tpFinal, s.r, s.armTime, s.entryTime, s.endTime, px, rr, why, s.ambiguous, s.confluenceSummary))
    if array.size(sdHistory) > 60
        array.pop(sdHistory)
    if s.ambiguous
        st.ambiguous += 1
    bool win = rr >= 0.05
    st.n := st.n + 1
    st.wins := st.wins + (win ? 1 : 0)
    st.scratch := st.scratch + (math.abs(rr) < 0.05 ? 1 : 0)
    st.sumR := st.sumR + rr
    if s.poolKind == "SWG"
        st.nSwg := st.nSwg + 1
        st.wSwg := st.wSwg + (win ? 1 : 0)
    else if s.poolKind == "EQH" or s.poolKind == "EQL"
        st.nEq := st.nEq + 1
        st.wEq := st.wEq + (win ? 1 : 0)
    else if s.poolKind == "SES"
        st.nSes := st.nSes + 1
        st.wSes := st.wSes + (win ? 1 : 0)
    else
        st.nHtf := st.nHtf + 1
        st.wHtf := st.wHtf + (win ? 1 : 0)
    if s.model == "iFVG"
        st.nIfvg := st.nIfvg + 1
        st.wIfvg := st.wIfvg + (win ? 1 : 0)
    else
        st.nMss := st.nMss + 1
        st.wMss := st.wMss + (win ? 1 : 0)
    s.state := 0
    s.dir := 0

cancelSetup(string why) =>
    ev.cancel := true
    ev.why := why
    ev.dir := s.dir
    s.endTime := time_close
    s.state := 0
    s.dir := 0

bool dirL = inDir != "Short only"
bool dirS = inDir != "Long only"

float observedRaidHi = na
float observedRaidLo = na
string observedKindHi = ""
string observedKindLo = ""
if barstate.isconfirmed and not na(atr)
    for p in pools
        if not p.swept
            [traversed, raid] = sdPoolObservation(p.side, p.px, high, low, close, inSweepMin * atrU)
            if traversed
                p.swept := true
                p.sweptBar := bar_index
                if raid and (inPoolMin == "Any pool" or p.kind != "SWG")
                    if p.side == 1 and (na(observedRaidHi) or sdCompare(p.px, observedRaidHi) > 0)
                        observedRaidHi := p.px
                        observedKindHi := p.kind
                    if p.side == -1 and (na(observedRaidLo) or sdCompare(p.px, observedRaidLo) < 0)
                        observedRaidLo := p.px
                        observedKindLo := p.kind

if barstate.isconfirmed and not na(atr) and bar_index > 50
    // FILLED: next-bar open ordering, then ambiguous range, then timeout. New stops apply next bar.
    if s.state == 4
        int d = s.dir
        [ended, exitPx, outcome, ambiguous] = sdOutcome(d, s.sl, s.tpFinal, open, high, low, close, s.fillBar, bar_index, inMaxTrade)
        if ended
            s.ambiguous := s.ambiguous or ambiguous
            string reason = outcome == "TARGET" ? ((not na(s.tpLiq) and sdCompare(s.tpFinal, s.tpLiq) == 0) ? "TP-LIQ" : "TP3") : outcome == "STOP" ? (s.tp2Hit ? "TRAIL" : (s.tp1Hit and inBE) ? "BE" : "SL") : outcome
            closeTrade(exitPx, reason + (ambiguous ? " · AMBIGUOUS" : ""))
        else
            [nextStop, one, two, newOne, newTwo] = sdMilestones(d, s.entry, s.sl, s.tp1, s.tp2, high, low, s.tp1Hit, s.tp2Hit, inBE, syminfo.mintick)
            s.sl := nextStop
            s.tp1Hit := one
            s.tp2Hit := two
            ev.tp1 := newOne
            ev.tp2 := newTwo
    // ── 3 · ARMED: waiting for the fill
    else if s.state == 3
        int d = s.dir
        [closeInsideZone, invalid, closeEntryAllowed] = sdPendingCloseDecision(d, close, s.fvgTop, s.fvgBot, s.sl)
        bool missed  = d == 1 ? sdCompare(high, s.tp1) >= 0 : sdCompare(low, s.tp1) <= 0
        bool fill = false
        float fillPx = s.entry
        if inEntryMode == "Close inside"
            fill := closeEntryAllowed
            fillPx := close
        else
            fill := d == 1 ? sdCompare(low, s.entry) <= 0 : sdCompare(high, s.entry) >= 0
            fillPx := d == 1 ? math.min(s.entry, open) : math.max(s.entry, open)
        bool gapPast = inEntryMode != "Close inside" and (d == 1 ? sdCompare(open, s.sl) <= 0 : sdCompare(open, s.sl) >= 0)
        if inEntryMode == "Close inside" and invalid
            cancelSetup("structural stop lost before close entry")
        else if fill and gapPast
            // Paper convention: limit and initial stop resolve at the same known open; 0R before costs, not a promised live scratch.
            s.entry := open
            s.state := 4
            s.fillBar := bar_index
            s.entryTime := time
            st.filled := st.filled + 1
            ev.filled := true
            ev.dir := d
            closeTrade(open, "GAP")
        else if fill
            if inEntryMode == "Close inside"
                setLevels(d, fillPx, false)
            else
                // A better limit fill changes actual risk, never the already-resting bracket.
                s.entry := fillPx
                s.r := sdDistance(s.sl0, fillPx, d, syminfo.mintick)
                s.planValid := sdCompare(s.r, 0) > 0
            if not s.planValid
                cancelSetup("Plan rejected: " + s.planReason)
            else
                s.state := 4
                s.fillBar := bar_index
                s.entryTime := time_close
                s.endTime := na
                st.filled += 1
                ev.filled := true
                ev.dir := d
                // A close entry cannot be stopped by earlier wicks. Resting limits may fill intrabar.
                if inEntryMode != "Close inside"
                    bool knownOpenFill = sdCompare(open, fillPx) == 0
                    if knownOpenFill
                        [endedAtOpenBar, priceAtOpenBar, reasonAtOpenBar, ambiguousAtOpenBar] = sdOutcome(d, s.sl, s.tpFinal, open, high, low, close, bar_index - 1, bar_index, inMaxTrade)
                        if endedAtOpenBar
                            s.ambiguous := ambiguousAtOpenBar
                            closeTrade(priceAtOpenBar, reasonAtOpenBar == "TARGET" ? ((not na(s.tpLiq) and sdCompare(s.tpFinal, s.tpLiq) == 0) ? "TP-LIQ" : "TP3") : "SL" + (ambiguousAtOpenBar ? " · AMBIGUOUS" : ""))
                        else
                            [nextStop, one, two, newOne, newTwo] = sdMilestones(d, s.entry, s.sl, s.tp1, s.tp2, high, low, false, false, inBE, syminfo.mintick)
                            s.sl := nextStop
                            s.tp1Hit := one
                            s.tp2Hit := two
                            ev.tp1 := newOne
                            ev.tp2 := newTwo
                    else
                        bool stopSame = d == 1 ? sdCompare(low, s.sl) <= 0 : sdCompare(high, s.sl) >= 0
                        bool targetSame = d == 1 ? sdCompare(high, s.tpFinal) >= 0 : sdCompare(low, s.tpFinal) <= 0
                        // An intrabar limit has unknown entry-vs-target chronology. Stop-first; do not invent a same-bar target win.
                        s.ambiguous := stopSame or targetSame
                        if stopSame
                            closeTrade(s.sl, "SL · AMBIGUOUS")
        else if invalid
            cancelSetup("invalidated before fill")
        else if s.model == "iFVG" and (d == 1 ? sdCompare(close, s.fvgBot) < 0 : sdCompare(close, s.fvgTop) > 0)
            cancelSetup("inversion failed — closed back through the zone")
        else if missed
            cancelSetup("missed — ran to TP1 unfilled")
        else if bar_index - s.armBar >= inExpire
            cancelSetup("expired unfilled")
    // ── 2 · SHIFTED: looking for the FVG in the leg
    else if s.state == 2
        int d = s.dir
        bool failed = d == 1 ? sdCompare(close, s.ext) < 0 : sdCompare(close, s.ext) > 0
        if failed
            cancelSetup("sweep extreme lost after the shift")
        else if bar_index - s.mssBar > inFvgWin
            cancelSetup("no FVG after the shift")
        else
            [ft, fb, fa] = findFvg(d, bar_index - s.sweepBar)
            if not na(ft)
                s.fvgTop := ft
                s.fvgBot := fb
                s.fvgBar := fa
                s.model := "MSS"
                float e = inEntryMode == "Confirmation close" ? close : inEntryMode == "CE (50%)" ? (ft + fb) / 2 : (d == 1 ? ft : fb)
                setLevels(d, e)
                if s.planValid
                    s.state := 3
                    s.armBar := bar_index
                    s.armTime := time_close
                    s.planId := time_close
                    s.entryTime := na
                    s.endTime := na
                    st.armed += 1
                    ev.armed := true
                else
                    cancelSetup("Plan rejected: " + s.planReason)
    // ── 1 · SWEPT: waiting for the confirmation (iFVG inversion and / or structure shift)
    else if s.state == 1
        int d = s.dir
        // the invalidation level follows the leg's extreme while we wait
        s.ext := d == 1 ? math.min(s.ext, low) : math.max(s.ext, high)
        bool failed = d == 1 ? sdCompare(close, s.poolPx - inDispAtr * atrU) < 0 : sdCompare(close, s.poolPx + inDispAtr * atrU) > 0
        if failed
            cancelSetup("no rejection — price kept going")
        else if bar_index - s.sweepBar > inShiftWin
            cancelSetup("no shift in time")
        else
            bool useIfvg = inConfirm != "Structure shift (MSS)"
            bool useMss  = inConfirm != "FVG inversion (iFVG)"
            bool shift = useMss and (d == 1 ? (not na(s.oppSwing) and sdCompare(close, s.oppSwing) > 0) : (not na(s.oppSwing) and sdCompare(close, s.oppSwing) < 0))
            bool disp  = sdDisplacement(d, close, s.ext, inDispAtr * atrU)
            // iFVG: an opposing gap printed near the raid that THIS bar closed through → it inverts; the nearest one to price is the zone
            float igTop = na
            float igBot = na
            int   igBar = na
            if useIfvg
                for g in gaps
                    if g.inverted and g.invBar == bar_index and g.dir == -d and g.bar >= s.sweepBar - inIfvgBack
                        bool beyondExt = d == 1 ? sdCompare(g.bot, s.ext) > 0 : sdCompare(g.top, s.ext) < 0
                        if beyondExt and (na(igTop) or (d == 1 ? sdCompare(g.top, igTop) > 0 : sdCompare(g.bot, igBot) < 0))
                            igTop := g.top
                            igBot := g.bot
                            igBar := g.bar
            if not na(igTop) and disp
                s.fvgTop := igTop
                s.fvgBot := igBot
                s.fvgBar := igBar
                s.model := "iFVG"
                s.mssBar := bar_index
                s.mssPx := d == 1 ? igTop : igBot
                st.shifted := st.shifted + 1
                ev.shift := true
                float e = inEntryMode == "Confirmation close" ? close : inEntryMode == "CE (50%)" ? (igTop + igBot) / 2 : (d == 1 ? igTop : igBot)
                setLevels(d, e)
                if s.planValid
                    s.state := 3
                    s.armBar := bar_index
                    s.armTime := time_close
                    s.planId := time_close
                    s.entryTime := na
                    s.endTime := na
                    st.armed += 1
                    ev.armed := true
                else
                    cancelSetup("Plan rejected: " + s.planReason)
            else if shift and disp
                s.state := 2
                s.mssBar := bar_index
                s.mssPx := s.oppSwing
                st.shifted := st.shifted + 1
                ev.shift := true
                // the FVG may already be in the leg — arm on the same bar
                [ft, fb, fa] = findFvg(d, bar_index - s.sweepBar)
                if not na(ft)
                    s.fvgTop := ft
                    s.fvgBot := fb
                    s.fvgBar := fa
                    s.model := "MSS"
                    float e = inEntryMode == "Confirmation close" ? close : inEntryMode == "CE (50%)" ? (ft + fb) / 2 : (d == 1 ? ft : fb)
                    setLevels(d, e)
                    if s.planValid
                        s.state := 3
                        s.armBar := bar_index
                        s.armTime := time_close
                        s.planId := time_close
                        s.entryTime := na
                        s.endTime := na
                        st.armed += 1
                        ev.armed := true
                    else
                        cancelSetup("Plan rejected: " + s.planReason)
    if ev.armed
        s.confluenceSummary := sdConfluenceSummary(s.dir)
        sdConfluenceConsume(s.dir)
    // Confirmation-close orders are accepted at this close; no earlier OHLC can end this plan.
    if ev.armed and inEntryMode == "Confirmation close" and s.planValid
        s.state := 4
        s.fillBar := bar_index
        s.entryTime := time_close
        st.filled += 1
        ev.filled := true
        ev.dir := s.dir
    // ── 0/1/2 · a fresh sweep replaces an idea that has not armed yet; an ARMED limit stands until filled / invalidated / missed / expired; a FILLED trade is never interrupted
    if s.state != 4 and s.state != 3 and not ev.exit and not ev.cancel and (not inSweepKz or inKz) and volOK
        float bestHi = observedRaidHi
        float bestLo = observedRaidLo
        string bkHi = observedKindHi
        string bkLo = observedKindLo
        // sell-side liquidity raided (a low taken, close back above) → long idea
        if not na(bestLo) and dirL
            s.dir := 1
            s.state := 1
            s.poolPx := bestLo
            s.poolKind := bkLo
            s.ext := low
            s.sweepBar := bar_index
            s.sweepKz := inKz
            s.sweepDepth := (bestLo - low) / atrU
            s.quality := sweepQuality(s.sweepDepth, bkLo, inKz)
            s.model := ""
            s.planId := na
            s.planValid := false
            s.planReason := "Waiting for confirmation"
            s.confluenceSummary := ""
            s.stopBasis := ""
            s.entry := na
            s.sl := na
            s.sl0 := na
            s.r := na
            s.tp1 := na
            s.tp2 := na
            s.tp3 := na
            s.tpLiq := na
            s.tpFinal := na
            s.armTime := na
            s.entryTime := na
            s.endTime := na
            s.ambiguous := false
            s.oppSwing := stHi
            s.oppBar := stHiBar
            s.tp1Hit := false
            s.tp2Hit := false
            st.swept := st.swept + 1
            ev.sweep := true
        else if not na(bestHi) and dirS
            s.dir := -1
            s.state := 1
            s.poolPx := bestHi
            s.poolKind := bkHi
            s.ext := high
            s.sweepBar := bar_index
            s.sweepKz := inKz
            s.sweepDepth := (high - bestHi) / atrU
            s.quality := sweepQuality(s.sweepDepth, bkHi, inKz)
            s.model := ""
            s.planId := na
            s.planValid := false
            s.planReason := "Waiting for confirmation"
            s.confluenceSummary := ""
            s.stopBasis := ""
            s.entry := na
            s.sl := na
            s.sl0 := na
            s.r := na
            s.tp1 := na
            s.tp2 := na
            s.tp3 := na
            s.tpLiq := na
            s.tpFinal := na
            s.armTime := na
            s.entryTime := na
            s.endTime := na
            s.ambiguous := false
            s.oppSwing := stLo
            s.oppBar := stLoBar
            s.tp1Hit := false
            s.tp2Hit := false
            st.swept := st.swept + 1
            ev.sweep := true

// ═══════════════════════════════════════════════════════════════════ CHART · pools, FVG, MSS, sweeps (drawings)
var array<box>   fvgBoxes = array.new<box>()
var array<line>  mssLines = array.new<line>()
var array<label> tags     = array.new<label>()
capBoxes(int cap) =>
    while array.size(fvgBoxes) > cap
        box.delete(array.shift(fvgBoxes))
capLines(int cap) =>
    while array.size(mssLines) > cap
        line.delete(array.shift(mssLines))
capTags(int cap) =>
    while array.size(tags) > cap
        label.delete(array.shift(tags))

bool showPools = vClean
bool showTags  = vPro
// a zone drawn as four stacked bands, densest at the edge price touches first (top for a long, bottom for a short), plus an outline
gradZone(int dir, float top, float bot, int x1, int x2) =>
    float h = (top - bot) / 4
    for k = 0 to 3
        float bTop = top - h * k
        float bBot = bTop - h
        int alpha = dir == 1 ? 80 + k * 4 : 92 - k * 4
        array.push(fvgBoxes, box.new(x1, bTop, x2, bBot, border_color = color.new(th.bg, 100), bgcolor = color.new(dir == 1 ? th.up : th.down, alpha)))
    array.push(fvgBoxes, box.new(x1, top, x2, bot, border_color = color.new(dir == 1 ? th.up : th.down, 40), bgcolor = color.new(th.bg, 100)))
if barstate.isconfirmed
    if ev.sweep and showTags
        array.push(tags, label.new(bar_index, s.dir == 1 ? low : high, (s.dir == 1 ? "SSL swept · " : "BSL swept · ") + s.poolKind, style = s.dir == 1 ? label.style_label_up : label.style_label_down, color = color.new(th.bg, 20), textcolor = s.dir == 1 ? th.up : th.down, size = size.tiny))
    if ev.shift and vClean and s.model == "MSS"
        array.push(mssLines, line.new(s.oppBar, s.mssPx, bar_index, s.mssPx, color = color.new(s.dir == 1 ? th.up : th.down, 25), style = line.style_dashed, width = 1))
        if showTags
            array.push(tags, label.new(bar_index, s.mssPx, "MSS", style = s.dir == 1 ? label.style_label_down : label.style_label_up, color = color.new(th.bg, 20), textcolor = s.dir == 1 ? th.up : th.down, size = size.tiny))
    if ev.armed and vClean
        gradZone(s.dir, s.fvgTop, s.fvgBot, s.fvgBar - 1, bar_index + inExtend)
        if showTags
            array.push(tags, label.new(s.fvgBar - 1, s.dir == 1 ? s.fvgTop : s.fvgBot, s.model == "iFVG" ? "iFVG ↺" : "FVG", style = label.style_label_right, color = color.new(th.bg, 100), textcolor = color.new(s.dir == 1 ? th.up : th.down, 20), size = size.tiny))
    capBoxes(40)
    capLines(30)
    capTags(60)

// pool lines: created once; x2 refreshed every 20 bars while resting (cheap); frozen + faded ONCE when swept.
// Far pools are deleted on the last bar, not just hidden — TradingView's auto-scale counts every line object.
if barstate.isconfirmed or (barstate.islastconfirmedhistory or barstate.islast)
    for p in pools
        if showPools
            if na(p.ln)
                p.ln := line.new(math.max(p.bar, bar_index - 250), p.px, bar_index + inExtend + 20, p.px, color = poolColor(p.side, false), width = p.kind == "EQH" or p.kind == "EQL" ? 2 : 1, style = line.style_solid)
                if inGlow and p.kind != "SWG"
                    p.ln2 := line.new(math.max(p.bar, bar_index - 250), p.px, bar_index + inExtend + 20, p.px, color = color.new(p.side == 1 ? th.down : th.up, 88), width = 5, style = line.style_solid)
            if p.swept and not p.faded
                line.set_x2(p.ln, nz(p.sweptBar, bar_index))
                line.set_color(p.ln, poolColor(p.side, true))
                line.set_style(p.ln, line.style_dotted)
                if not na(p.ln2)
                    line.delete(p.ln2)
                    p.ln2 := na
                p.faded := true
            else if not p.swept and bar_index % 20 == 0
                line.set_x2(p.ln, bar_index + inExtend + 20)
                if not na(p.ln2)
                    line.set_x2(p.ln2, bar_index + inExtend + 20)
        else
            if not na(p.ln)
                line.delete(p.ln)
                p.ln := na
            if not na(p.ln2)
                line.delete(p.ln2)
                p.ln2 := na
// focus + labels: last bar only (display)
if (barstate.islastconfirmedhistory or barstate.islast)
    for p in pools
        bool near = math.abs(p.px - close) <= inPoolNear * atrU
        if showPools and near
            if not na(p.ln) and not p.swept
                line.set_x2(p.ln, bar_index + inExtend)
                if not na(p.ln2)
                    line.set_x2(p.ln2, bar_index + inExtend)
            if showTags and not p.swept
                string ptxt = (p.side == 1 ? "BSL" : "SSL") + " · " + p.kind + (p.touches > 1 ? " ×" + str.tostring(p.touches) : "")
                if na(p.lb)
                    p.lb := label.new(bar_index + inExtend, p.px, ptxt, style = label.style_label_left, color = color.new(th.bg, 100), textcolor = color.new(p.side == 1 ? th.down : th.up, 25), size = size.tiny)
                else
                    label.set_xy(p.lb, bar_index + inExtend, p.px)
                    label.set_text(p.lb, ptxt)
            else if not na(p.lb)
                label.delete(p.lb)
                p.lb := na
        else
            if not na(p.ln)
                line.delete(p.ln)
                p.ln := na
                p.faded := false
            if not na(p.ln2)
                line.delete(p.ln2)
                p.ln2 := na
            if not na(p.lb)
                label.delete(p.lb)
                p.lb := na

// ═══════════════════════════════════════════════════════════════════ CHART · the ticket (plots + gradient zones)
bool pend   = false
bool live   = false
int  zDir   = s.dir != 0 ? s.dir : ev.dir
float pEntry = pend ? s.entry : na
float pStop  = pend ? s.sl : na
float pTpF   = pend ? s.tpFinal : na
float aEntry = live ? s.entry : na
float aStop  = live ? s.sl : na
float aTp1   = live ? s.tp1 : na
float aTp2   = live ? s.tp2 : na
float aTpF   = live ? s.tpFinal : na
float aTpLiq = live and not na(s.tpLiq) and sdCompare(s.tpLiq, s.tpFinal) != 0 ? s.tpLiq : na
plot(pEntry, "Pending · entry", color = color.new(th.txtHi, 45), linewidth = 1, style = plot.style_circles, display = display.pane)
plot(pStop,  "Pending · stop",  color = color.new(th.down, 45),  linewidth = 1, style = plot.style_circles, display = display.pane)
plot(pTpF,   "Pending · target", color = color.new(th.up, 45),   linewidth = 1, style = plot.style_circles, display = display.pane)
plEnt  = plot(aEntry, "Trade · entry",  color = color.new(th.txtHi, 10), linewidth = 1, style = plot.style_linebr, display = display.pane)
plStop = plot(aStop,  "Trade · stop",   color = color.new(th.down, 10),  linewidth = 1, style = plot.style_linebr, display = display.pane)
plot(aTp1, "Trade · TP1", color = color.new(th.up, 60), linewidth = 1, style = plot.style_linebr, display = display.pane)
plot(aTp2, "Trade · TP2", color = color.new(th.up, 45), linewidth = 1, style = plot.style_linebr, display = display.pane)
plFin  = plot(aTpF,   "Trade · final target", color = color.new(th.up, 10), linewidth = 1, style = plot.style_linebr, display = display.pane)
plot(aTpLiq, "Trade · liquidity target", color = color.new(th.accentHi, 25), linewidth = 1, style = plot.style_linebr, display = display.pane)
bool zonesOn = inZones and live
fill(plEnt, plStop, top_value = math.max(aEntry, aStop), bottom_value = math.min(aEntry, aStop),
     top_color = zonesOn and zDir == 1 ? color.new(th.down, 78) : color.new(th.down, 100), bottom_color = zonesOn and zDir == -1 ? color.new(th.down, 78) : color.new(th.down, 100), title = "Risk zone", fillgaps = false)
fill(plEnt, plFin, top_value = math.max(aEntry, aTpF), bottom_value = math.min(aEntry, aTpF),
     top_color = zonesOn and zDir == -1 ? color.new(th.up, 80) : color.new(th.up, 100), bottom_color = zonesOn and zDir == 1 ? color.new(th.up, 80) : color.new(th.up, 100), title = "Reward zone", fillgaps = false)
bgcolor(vFull ? (kz1 ? color.new(th.accent, 94) : kz2 ? color.new(th.up, 95) : kz3 ? color.new(th.down, 95) : na) : na, title = "Killzone shading")
barcolor(inTint and s.state == 4 ? color.new(s.dir == 1 ? th.up : th.down, 55) : na, title = "Trade tint")
plotshape(ev.sweep and s.dir == 1 and vClean,  "Raid · sell-side", shape.circle, location.belowbar, color = color.new(th.up, 35),   size = size.tiny, display = display.pane)
plotshape(ev.sweep and s.dir == -1 and vClean, "Raid · buy-side",  shape.circle, location.abovebar, color = color.new(th.down, 35), size = size.tiny, display = display.pane)
plotshape(ev.filled and ev.dir == 1,  "Filled · long",  shape.triangleup,   location.belowbar, color = th.up,   size = size.tiny, display = display.pane)
plotshape(ev.filled and ev.dir == -1, "Filled · short", shape.triangledown, location.abovebar, color = th.down, size = size.tiny, display = display.pane)

// event labels (confirmed bars only by construction)
if ev.armed
    label.new(s.fvgBar, s.dir == 1 ? s.fvgBot : s.fvgTop, (s.dir == 1 ? "LONG armed" : "SHORT armed") + " · " + fmt(s.entry), style = s.dir == 1 ? label.style_label_up : label.style_label_down, color = color.new(s.dir == 1 ? th.up : th.down, 20), textcolor = th.bg, size = size.small,
         tooltip = "entry " + fmt(s.entry) + "\nstop " + fmt(s.sl) + " (" + fmt(s.r) + " = 1R, " + (s.sl0 == s.ext - inSlBuf * atrU or s.sl0 == s.ext + inSlBuf * atrU ? "sweep extreme" : "zone far edge") + ")\nzone " + (s.model == "iFVG" ? "inverted gap " : "FVG ") + fmt(s.fvgBot) + " – " + fmt(s.fvgTop) + "\nTP1 " + fmt(s.tp1) + " · TP2 " + fmt(s.tp2) + " · TP3 " + fmt(s.tp3) + (na(s.tpLiq) ? "\nno liquidity target ≥ " + str.tostring(inTpLiqMinR, "0.0") + "R" : "\nliquidity " + fmt(s.tpLiq) + " (" + s.tpLiqKind + ", " + str.tostring(math.abs(s.tpLiq - s.entry) / s.r, "0.0") + "R)"))
if ev.exit
    string rTxt = (ev.r >= 0 ? "+" : "") + str.tostring(ev.r, "0.0") + "R"
    label.new(bar_index, ev.dir == 1 ? high : low, (ev.dir == 1 ? "XL" : "XS") + " · " + ev.why + " " + rTxt, style = ev.dir == 1 ? label.style_label_down : label.style_label_up, color = color.new(th.bg, 15), textcolor = ev.r >= 0 ? th.up : th.down, size = size.tiny)
if ev.cancel and vPro
    label.new(bar_index, ev.dir == 1 ? high : low, "✕ " + ev.why, style = ev.dir == 1 ? label.style_label_down : label.style_label_up, color = color.new(th.bg, 40), textcolor = th.flat, size = size.tiny)

// ═══════════════════════════════════════════════════════════════════ WORDS (one source of truth)
string stateWord = s.state == 4 ? "FILLED · " + (s.dir == 1 ? "LONG" : "SHORT") : s.state == 3 ? "ARMED · " + (s.dir == 1 ? "LONG" : "SHORT") : s.state == 2 ? "SHIFTED · " + (s.dir == 1 ? "long" : "short") + " idea" : s.state == 1 ? "SWEPT · waiting for confirmation" : "SCANNING"
color  stateColor = s.state >= 3 ? (s.dir == 1 ? th.up : th.down) : s.state > 0 ? th.accentHi : th.txt
string clockTxt  = na(sdLastClosedTime) ? "waiting close" : str.format_time(sdLastClosedTime, "HH:mm", inTZ)
var int restingUp = 0
var int restingDn = 0
if (barstate.islastconfirmedhistory or barstate.islast)
    restingUp := 0
    restingDn := 0
    for p in pools
        if not p.swept
            if p.side == 1
                restingUp += 1
            else
                restingDn += 1
float rrLiq = s.state >= 3 and not na(s.tpLiq) ? math.abs(s.tpLiq - s.entry) / s.r : na

// ═══════════════════════════════════════════════════════════════════ DASHBOARD
posOf(string p) =>
    p == "Top Left" ? position.top_left : p == "Bottom Right" ? position.bottom_right : p == "Bottom Left" ? position.bottom_left : p == "Middle Right" ? position.middle_right : p == "Middle Left" ? position.middle_left : position.top_right
szLead() => inSize == "Small" ? size.small : inSize == "Large" ? size.large  : size.normal
szBody() => inSize == "Small" ? size.tiny  : inSize == "Large" ? size.normal : size.small
szSub()  => inSize == "Small" ? size.tiny  : inSize == "Large" ? size.small  : size.tiny
kv(table tb, int row, string k, string v, color vc, string tip) =>
    color stripe = inZebra and row % 2 == 1 ? color.new(th.accent, 93) : th.bg
    table.cell(tb, 0, row, k, text_color = th.flat, text_size = szBody(), text_halign = text.align_left, bgcolor = stripe, tooltip = tip)
    table.cell(tb, 1, row, v, text_color = vc, text_size = szBody(), text_halign = text.align_right, text_font_family = font.family_monospace, bgcolor = stripe, tooltip = tip)

// Read-only admission diagnostics. They never replace an active plan's state or frozen context.
sdViewAdmissionData() =>
    string status = ""
    if inCfPda and not cfHtfAllowed
        status := "SETTINGS: HTF FVG must exceed chart"
    else if inCfPda and not cfSourceReady
        status := "DATA WAIT: completed HTF source missing"
    if inCfSmt and not cfPair.ready
        status += (status == "" ? "" : "\n") + "SMT: waiting for aligned closed bars"
    status == "" ? "Data ready; setup gates still apply" : status
bool sdViewDataWait = (inCfPda and not cfSourceReady) or (inCfSmt and not cfPair.ready)
string sdViewDataHelp = "New-setup eligibility only. Set the required HTF FVG source strictly above the chart; missing completed source data blocks admission. Enabled SMT requires contiguous aligned closed-bar history. Existing pending/active plans retain their frozen context and lifecycle. Data ready does not mean a trade signal."

// At most 18 populated rows including admission status, within the 20-row allocation.
var table dash = table.new(posOf(inDashPos), 2, 20, bgcolor = th.bg, frame_color = th.frame, frame_width = 1, border_width = 0)
if (barstate.islastconfirmedhistory or barstate.islast) and inDash
    table.clear(dash, 0, 0, 1, 19)
    int r = 0
    table.cell(dash, 0, r, "SWEEP DESK", text_color = th.accentHi, text_size = szLead(), text_halign = text.align_left, bgcolor = th.bg, tooltip = "Liquidity → shift → FVG, with a close-committed entry / stop / target engine. Every step commits on the closed bar.")
    table.cell(dash, 1, r, syminfo.ticker + " · " + timeframe.period, text_color = th.accent, text_size = szSub(), text_halign = text.align_right, bgcolor = th.bg)
    r += 1
    table.cell(dash, 0, r, (s.state >= 3 ? (s.dir == 1 ? "▲ " : "▼ ") : "● ") + stateWord, text_color = stateColor, text_size = szLead(), text_halign = text.align_left, bgcolor = th.bg, tooltip = "SCANNING = no raid yet · SWEPT = a pool was raided, waiting for the structure shift · SHIFTED = shift confirmed, looking for the FVG · ARMED = levels set, waiting for the fill · FILLED = trade open.")
    table.cell(dash, 1, r, s.state == 1 ? str.tostring(bar_index - s.sweepBar) + "/" + str.tostring(inShiftWin) + " bars" : s.state == 3 ? str.tostring(bar_index - s.armBar) + "/" + str.tostring(inExpire) + " bars" : s.state == 4 ? str.tostring(bar_index - s.fillBar) + " bars in" : clockTxt + " · " + kzName, text_color = th.txt, text_size = szBody(), text_halign = text.align_right, bgcolor = th.bg, tooltip = "Timers: bars since the sweep (shift window) · bars since armed (expiry) · bars in the trade.")
    r += 1
    kv(dash, r, "New setups · data", sdViewAdmissionData(), sdViewDataWait ? th.down : th.flat, sdViewDataHelp)
    r += 1
    kv(dash, r, "Pool", s.state > 0 ? (s.dir == 1 ? "SSL " : "BSL ") + s.poolKind + " " + fmt(s.poolPx) + " · " + str.tostring(s.sweepDepth, "0.00") + " ATR" : str.tostring(restingUp) + " BSL · " + str.tostring(restingDn) + " SSL resting", th.txt, "The raided pool: its kind (SWG swing · EQH/EQL equal highs/lows · SES killzone session · HTF higher-timeframe swing · PDH/PDL/PWH/PWL previous day/week) and how far the wick went through it in ATR. When scanning: how many pools are resting on each side.")
    r += 1
    // the level rows only exist while a setup is alive — an idle dashboard stays short
    if s.state > 0
        kv(dash, r, "Sweep", s.state > 0 ? (s.sweepKz ? "in killzone" : "outside killzones") + (inSweepVol ? " · vol ✓" : "") : "—", s.state > 0 ? (s.sweepKz ? th.up : th.txt) : th.flat, "Where the sweep happened (killzone or not) and whether the volume filter passed.")
        r += 1
        float qv = s.state > 0 ? nz(s.quality, 0.0) : na
        string qBar = na(qv) ? "—" : (qv >= 0.8 ? "▮▮▮▮▮" : qv >= 0.6 ? "▮▮▮▮▯" : qv >= 0.4 ? "▮▮▮▯▯" : qv >= 0.2 ? "▮▮▯▯▯" : "▮▯▯▯▯") + " " + str.tostring(math.round(qv * 100))
        color qBg = na(qv) ? th.bg : color.new(color.from_gradient(qv, 0.0, 1.0, th.down, th.up), 72)
        table.cell(dash, 0, r, "Sweep quality", text_color = th.flat, text_size = szBody(), text_halign = text.align_left, bgcolor = th.bg, tooltip = "Descriptive, frozen at the raid: depth of the wick through the pool, strength of the pool kind, killzone, volume. It never gates a setup and it is not a prediction.")
        table.cell(dash, 1, r, qBar, text_color = th.txtHi, text_size = szBody(), text_halign = text.align_right, text_font_family = font.family_monospace, bgcolor = qBg, tooltip = "Descriptive, frozen at the raid: depth of the wick through the pool, strength of the pool kind, killzone, volume. It never gates a setup and it is not a prediction.")
        r += 1
        string needTxt = inConfirm == "FVG inversion (iFVG)" ? "needs a close through an opposing gap" : inConfirm == "Structure shift (MSS)" ? "needs close " + (s.dir == 1 ? "above " : "below ") + (na(s.oppSwing) ? "—" : fmt(s.oppSwing)) : "close " + (s.dir == 1 ? "above " : "below ") + (na(s.oppSwing) ? "—" : fmt(s.oppSwing)) + " or through a gap"
        kv(dash, r, "Confirm", s.state >= 2 ? (s.model == "iFVG" ? "iFVG inverted" : "MSS " + fmt(s.mssPx)) + " · " + str.tostring(math.abs(close - s.ext) / atrU, "0.0") + " ATR leg" : s.state == 1 ? needTxt : "—", s.state >= 2 ? th.txtHi : th.txt, "How the reversal was confirmed: iFVG = a close through an opposing gap near the raid (the gap inverts) · MSS = a close beyond the last short-term swing with displacement. Plus the leg from the sweep extreme in ATR.")
        r += 1
        kv(dash, r, "Zone", s.state >= 3 ? fmt(s.fvgBot) + " – " + fmt(s.fvgTop) + (s.model == "iFVG" ? " · inverted gap" : " · leg FVG") : "—", th.txt, "The entry zone: the inverted gap (iFVG) or the imbalance inside the shift leg (MSS).")
        r += 1
        if s.state >= 3
            kv(dash, r, "Confluence · frozen", s.confluenceSummary, th.txt, "Observed at acceptance, before its first-touch token was consumed. A transparent checklist, not a probability or private grade.")
            r += 1
        kv(dash, r, "Entry", s.state >= 3 ? fmt(s.entry) + (s.state == 3 ? (inEntryMode == "Close inside" ? " · waiting close" : " · limit") : "") : "—", th.txtHi, "Confirmation close (default), later close inside, or resting edge/midpoint: see Entry setting. Paper reference; broker fills may differ.")
        r += 1
        kv(dash, r, "Stop", s.state >= 3 ? fmt(s.sl) + " · " + fmt(s.r) + " = 1R" + (s.tp2Hit ? " · TRAIL" : s.tp1Hit and inBE ? " · BE" : "") : "—", th.down, "Anchored to the sweep extreme (structural invalidation) plus a buffer — or the zone's far edge when the extreme is too far away (Auto) or when you chose it.")
        r += 1
        kv(dash, r, "R ladder", s.state >= 3 ? fmt(s.tp1) + " · " + fmt(s.tp2) + " · " + fmt(s.tp3) : "—", th.up, "Rounded R milestones. TP1 entry-stop move is optional; TP2 trails to TP1. Updates apply next bar, with no partial position exits.")
        r += 1
        kv(dash, r, "Liquidity", s.state >= 3 ? (na(s.tpLiq) ? "none ≥ " + str.tostring(inTpLiqMinR, "0.0") + "R resting" : fmt(s.tpLiq) + " · " + s.tpLiqKind + " · " + str.tostring(rrLiq, "0.0") + "R") : "—", na(rrLiq) ? th.flat : th.accentHi, "The nearest resting pool on the other side — where the market's next magnet sits. Final target = " + inPrimary + ".")
        r += 1
    kv(dash, r, "Swept · Confirm · Armed · Filled", str.tostring(st.swept) + " · " + str.tostring(st.shifted) + " · " + str.tostring(st.armed) + " · " + str.tostring(st.filled), th.txt, "How many raids were confirmed (iFVG or MSS), how many confirmations armed a zone, how many got filled — on the loaded history. The drop-offs are the filters doing their job.")
    r += 1
    int decided = st.n - st.scratch
    kv(dash, r, "Record", st.n < 5 ? str.tostring(st.n) + " closed · too few" : str.tostring(st.n) + " closed · " + str.tostring(math.round(st.wins / st.n * 100)) + "% win" + (st.scratch > 0 ? " · " + str.tostring(st.scratch) + " flat" : "") + " · avg " + (st.sumR / st.n >= 0 ? "+" : "") + str.tostring(st.sumR / st.n, "0.00") + "R", th.txt, "Closed trades on this chart: count · win% of ALL closed (flat = breakeven / gap scratches, counted as non-wins, same basis as the By-pool and By-model rows) · average R. Paper, no costs, small sample — a description of the past on THIS chart, not an edge.")
    r += 1
    string byPool = (st.nSwg > 0 ? "SWG " + str.tostring(math.round(st.wSwg / st.nSwg * 100)) + "%/" + str.tostring(st.nSwg) : "SWG —") + " · " + (st.nEq > 0 ? "EQ " + str.tostring(math.round(st.wEq / st.nEq * 100)) + "%/" + str.tostring(st.nEq) : "EQ —") + " · " + (st.nSes > 0 ? "SES " + str.tostring(math.round(st.wSes / st.nSes * 100)) + "%/" + str.tostring(st.nSes) : "SES —") + " · " + (st.nHtf > 0 ? "HTF " + str.tostring(math.round(st.wHtf / st.nHtf * 100)) + "%/" + str.tostring(st.nHtf) : "HTF —")
    kv(dash, r, "By pool", byPool, th.txt, "Win% / count by the kind of liquidity that was raided — swing, equal highs/lows, killzone session, or HTF (higher-timeframe swings + previous day / week). Which pools actually pay on YOUR chart.")
    r += 1
    string byModel = (st.nIfvg > 0 ? "iFVG " + str.tostring(math.round(st.wIfvg / st.nIfvg * 100)) + "%/" + str.tostring(st.nIfvg) : "iFVG —") + " · " + (st.nMss > 0 ? "MSS " + str.tostring(math.round(st.wMss / st.nMss * 100)) + "%/" + str.tostring(st.nMss) : "MSS —")
    kv(dash, r, "By model", byModel, th.txt, "Win% / count by confirmation model — the inversion (iFVG) entries versus the structure-shift (MSS) entries on this chart.")
    r += 1
    table.cell(dash, 0, r, (inConfirm == "Either" ? "iFVG+MSS" : inConfirm == "FVG inversion (iFVG)" ? "iFVG" : "MSS") + " · " + inEntryMode + " · " + (inSlMode == "Sweep extreme" ? "SL@sweep" : inSlMode == "Zone far edge" ? "SL@zone" : "SL@auto") + " · " + (inPrimary == "Liquidity" ? "TP@liq" : "TP@" + str.tostring(inTp3) + "R"), text_color = th.flat, text_size = szSub(), text_halign = text.align_left, bgcolor = th.bg, tooltip = "Entry mode · stop anchor · final target.")
    table.cell(dash, 1, r, (inSweepKz ? "killzones only" : "any time") + " · " + str.tostring(array.size(pools)) + " pools", text_color = th.flat, text_size = szSub(), text_halign = text.align_right, bgcolor = th.bg, tooltip = "Sweep time filter and how many pools (resting + recently swept) are tracked.")

// ═══════════════════════════════════════════════════════════════════ NARRATIVE · Co-Pilot (translates state; never a new signal)
wrap(string sIn, int width) =>
    array<string> words = str.split(sIn, " ")
    string out  = ""
    string line = ""
    for wd in words
        if line != "" and str.length(line) + str.length(wd) + 1 > width
            out  := out + (out == "" ? "" : "\n") + line
            line := wd
        else
            line := line == "" ? wd : line + " " + wd
    out == "" ? line : out + "\n" + line

narrNow() =>
    string o = ""
    if s.state == 4
        float finalR = (s.tpFinal - s.entry) * s.dir / s.r
        o := "paper " + (s.dir == 1 ? "long" : "short") + " from " + fmt(s.entry) + " — current stop " + fmt(s.sl) + (s.tp2Hit ? " (TP2 trail)" : s.tp1Hit and inBE ? " (entry stop)" : " (" + s.stopBasis + ")") + ", final " + fmt(s.tpFinal) + " (" + str.tostring(finalR, "0.00") + "R" + ((not na(s.tpLiq) and sdCompare(s.tpFinal, s.tpLiq) == 0) ? "; " + s.tpLiqKind : "; fixed ladder") + ")"
    else if s.state == 3
        o := (s.dir == 1 ? "long" : "short") + " plan armed — " + (inEntryMode == "Close inside" ? "waiting for a valid close inside " + fmt(s.fvgBot) + "–" + fmt(s.fvgTop) : "resting " + inEntryMode + " limit " + fmt(s.entry)) + "; pending stop " + fmt(s.sl0) + " (" + s.stopBasis + "); " + str.tostring(math.max(0, inExpire - (bar_index - s.armBar))) + " bars until unfilled expiry"
    else if s.state == 2
        o := "structure shifted " + (s.dir == 1 ? "up" : "down") + " after the raid — looking for the imbalance in the leg to arm the entry (" + str.tostring(inFvgWin - (bar_index - s.mssBar)) + " bars left)"
    else if s.state == 1
        o := (s.dir == 1 ? "sell-side" : "buy-side") + " liquidity at " + fmt(s.poolPx) + " (" + s.poolKind + ") was raided and rejected — waiting for " + (inConfirm == "FVG inversion (iFVG)" ? "a close through an opposing gap (inversion)" : inConfirm == "Structure shift (MSS)" ? "a close " + (s.dir == 1 ? "above " : "below ") + (na(s.oppSwing) ? "the last short-term swing" : fmt(s.oppSwing)) : "a close through an opposing gap or " + (s.dir == 1 ? "above " : "below ") + (na(s.oppSwing) ? "the last short-term swing" : fmt(s.oppSwing))) + " with displacement, " + str.tostring(inShiftWin - (bar_index - s.sweepBar)) + " bars left"
    else
        o := "scanning — " + str.tostring(restingUp) + " buy-side and " + str.tostring(restingDn) + " sell-side pools are resting; nothing has been raided yet"
    o
narrWhy() =>
    string o = "The sequence is mechanical: a pool is swept (wick through by ≥ " + str.tostring(inSweepMin, "0.00") + " ATR, close back inside), then the reversal is confirmed " + (inConfirm == "FVG inversion (iFVG)" ? "when a bar closes through an opposing gap — the gap inverts and becomes the zone" : inConfirm == "Structure shift (MSS)" ? "when a bar closes beyond the last short-term swing — the gap in that leg becomes the zone" : "by a close through an opposing gap (the gap inverts) or beyond the last short-term swing (the gap in that leg)") + ", always with a leg ≥ " + str.tostring(inDispAtr, "0.0") + " ATR from the extreme. "
    o := o + (s.state > 0 ? "This one: " + s.poolKind + " pool, wick " + str.tostring(s.sweepDepth, "0.00") + " ATR through it, " + (s.sweepKz ? "inside a killzone" : "outside the killzones") + "." : "Killzone now: " + kzName + ".")
    o
narrPlan() =>
    string o = ""
    if s.state >= 3
        float finalR = (s.tpFinal - s.entry) * s.dir / s.r
        string targetBasis = (not na(s.tpLiq) and sdCompare(s.tpFinal, s.tpLiq) == 0) ? "nearest qualifying " + s.tpLiqKind : inPrimary == "Liquidity" and na(s.tpLiq) ? "explicit TP3 fallback" : "selected TP3 rung"
        o := "Entry " + fmt(s.entry) + "; initial stop " + fmt(s.sl0) + " (" + s.stopBasis + "), current stop " + fmt(s.sl) + ". TP1 " + fmt(s.tp1) + (inBE ? " moves the stop toward entry" : " is a checkpoint; entry-stop move is off") + "; TP2 " + fmt(s.tp2) + " moves it toward TP1. These stop changes apply from the next bar. Final " + fmt(s.tpFinal) + " = " + str.tostring(finalR, "0.00") + "R (" + targetBasis + "). Known open gaps resolve first; stop-first only for unordered range hits. Closed-bar entries exclude all earlier entry-bar wicks."
    else if s.state == 1
        o := (inConfirm == "FVG inversion (iFVG)" ? "If a bar closes through an opposing gap near the raid with displacement, that gap inverts and the entry is armed on it. " : inConfirm == "Structure shift (MSS)" ? "If a bar closes " + (s.dir == 1 ? "above " : "below ") + (na(s.oppSwing) ? "the last short-term swing" : fmt(s.oppSwing)) + " with displacement, the shift is in. " : "A close through an opposing gap (inversion) arms the entry on that gap; a close " + (s.dir == 1 ? "above " : "below ") + (na(s.oppSwing) ? "the last short-term swing" : fmt(s.oppSwing)) + " arms it on the gap in that leg. ") + "If price closes back through the raided pool by " + str.tostring(inDispAtr, "0.0") + " ATR, the raid was not a raid."
    o
narrTrack() =>
    int decided = st.n - st.scratch
    "Funnel here: " + str.tostring(st.swept) + " sweeps → " + str.tostring(st.shifted) + " confirmed → " + str.tostring(st.armed) + " armed → " + str.tostring(st.filled) + " filled. " + (st.n < 5 ? "Too few closed trades to say anything yet." : str.tostring(st.n) + " closed, " + str.tostring(math.round(st.wins / st.n * 100)) + "% won" + (st.scratch > 0 ? " (" + str.tostring(st.scratch) + " flat)" : "") + ", avg " + (st.sumR / st.n >= 0 ? "+" : "") + str.tostring(st.sumR / st.n, "0.00") + "R (paper, no costs, small sample — NOT an edge).")
narrPosOf(string p) =>
    p == "Top Left" ? position.top_left : p == "Top Right" ? position.top_right : p == "Bottom Right" ? position.bottom_right : p == "Middle Left" ? position.middle_left : p == "Middle Right" ? position.middle_right : position.bottom_left
narrBodySz() => inNarrSize == "Small" ? size.small : inNarrSize == "Medium" ? size.normal : inNarrSize == "Large" ? size.large : szBody()
narrHeadSz() => inNarrSize == "Small" ? size.normal : inNarrSize == "Medium" ? size.large : inNarrSize == "Large" ? size.large : szLead()

var table narrT = na
if (barstate.islastconfirmedhistory or barstate.islast)
    if not na(narrT)
        table.delete(narrT)
        narrT := na
    if inNarr != "Off"
        array<string> labs = array.new<string>()
        array<string> vals = array.new<string>()
        array.push(labs, "NOW")
        array.push(vals, wrap(narrNow() + " (decision support, not a trade instruction)", inNarrWrap))
        bool deep = inNarr == "Standard" or inNarr == "Detailed"
        if deep
            array.push(labs, "WHY")
            array.push(vals, wrap(narrWhy(), inNarrWrap))
            string p = narrPlan()
            if p != ""
                array.push(labs, "PLAN")
                array.push(vals, wrap(p, inNarrWrap))
        if inNarr == "Detailed"
            array.push(labs, "TRACK REC")
            array.push(vals, wrap(narrTrack(), inNarrWrap))
            array.push(labs, "GLOSSARY")
            array.push(vals, wrap("Pool = resting liquidity (stops) at a swing, equal highs/lows, a session or a day/week extreme. Sweep = a wick through it that closes back. iFVG = an opposing gap that price closes through, so it inverts and becomes the zone. MSS = a close beyond the last short-term swing the other way; then the gap in that leg is the zone. The selected stop anchor is sweep extreme, zone far edge, or Auto; the accepted ticket shows its actual basis.", inNarrWrap))
        int nr = array.size(labs)
        bool atBottom = inNarrPos == "Bottom Left" or inNarrPos == "Bottom Right"
        int rows = nr + 2 + (atBottom ? 1 : 0)
        narrT := table.new(narrPosOf(inNarrPos), 2, rows, bgcolor = th.bg, frame_color = th.frame, frame_width = 1, border_color = color.new(th.frame, 70), border_width = 1)
        table.cell(narrT, 0, 0, "◆ CO-PILOT", text_color = th.accentHi, bgcolor = color.new(th.accent, 88), text_size = narrHeadSz(), text_halign = text.align_left)
        table.cell(narrT, 1, 0, stateWord + " · " + str.upper(inNarr), text_color = th.accent, bgcolor = color.new(th.accent, 88), text_size = narrBodySz(), text_halign = text.align_right)
        for i = 0 to nr - 1
            table.cell(narrT, 0, i + 1, array.get(labs, i), text_color = th.accent, bgcolor = th.bg, text_size = narrBodySz(), text_halign = text.align_left, text_valign = text.align_top)
            table.cell(narrT, 1, i + 1, array.get(vals, i), text_color = th.txt, bgcolor = th.bg, text_size = narrBodySz(), text_halign = text.align_left, text_valign = text.align_top)
        table.cell(narrT, 0, nr + 1, "", bgcolor = th.bg)
        table.cell(narrT, 1, nr + 1, "Honest: levels are geometry, not promises. Paper ledger has no costs; the strategy twin measures it with costs.", text_color = th.flat, bgcolor = th.bg, text_size = narrBodySz(), text_halign = text.align_left)
        if atBottom
            table.cell(narrT, 0, nr + 2, " ", bgcolor = th.bg)
            table.cell(narrT, 1, nr + 2, " ", bgcolor = th.bg)

// ── BROKER OWNERSHIP BEGIN · no paper EXIT/CANCEL/SYNC determines a broker exit
gLab = "Strategy Lab · account risk"
labMode = input.string("Cash risk", "Lab sizing", options = ["Cash risk", "Fixed units"], group = gLab, display = display.none)
labRisk = input.float(100, "Cash risk · account currency", minval = 0.01, group = gLab, display = display.none, tooltip = "Signal-close stop-distance budget. Uses strategy account conversion and symbol point value. Commissions, slippage and gaps can increase loss beyond this estimate.")
labFixed = input.float(1, "Fixed units", minval = 0.000001, group = gLab, display = display.none, active = labMode == "Fixed units")
labCap = input.float(90, "Maximum notional · % current equity", minval = 1, maxval = 100, group = gLab, display = display.none, tooltip = "Also caps Fixed units. Does not change 100% margin or eliminate margin calls after price movement.")
labStepMode = input.string("Symbol minimum", "Quantity increment", options = ["Symbol minimum", "Manual"], group = gLab, display = display.none)
labStep = input.float(1, "Manual quantity increment", minval = 0.00000001, group = gLab, display = display.none, active = labStepMode == "Manual")
var string brokerStatus = "Waiting for accepted technical plan"
var float brokerQuantity = na
var float brokerRiskEstimate = na
var float brokerNotional = na
var int brokerState = 0 // 0 flat, 1 submitted/resting, 2 position observed
var int brokerDirection = 0
var int brokerSubmitBar = na
var int brokerFillBar = na
var int brokerLastExitBar = na
var int brokerClosedSeen = 0
var float brokerEntry = na
var float brokerStop = na
var float brokerInitialStop = na
var float brokerTarget = na
var float brokerTp1 = na
var float brokerTp2 = na
var float brokerZoneHigh = na
var float brokerZoneLow = na
var bool brokerTp1Hit = false
var bool brokerTp2Hit = false
var string brokerModel = ""
bool brokerSubmitted = false
bool brokerClosedThisBar = false
if barstate.isconfirmed
    bool brokerOccupied = sdSign(strategy.position_size) != 0
    if strategy.closedtrades > brokerClosedSeen
        // Partial margin liquidation does not release ownership of a nonzero remainder.
        brokerClosedSeen := strategy.closedtrades
        if not brokerOccupied
            strategy.cancel("Entry")
            strategy.cancel("Bracket")
            brokerState := 0
            brokerLastExitBar := bar_index
            brokerClosedThisBar := true
            brokerStatus := "Broker position closed; no same-bar replacement"
    if brokerState == 1 and brokerOccupied
        brokerState := 2
        brokerEntry := strategy.position_avg_price
        brokerFillBar := strategy.opentrades.entry_bar_index(0)
        brokerStatus := "Broker fill observed; frozen bracket owned"
    if brokerState == 1 and not brokerOccupied and bar_index > brokerSubmitBar
        bool marketMode = inEntryMode == "Close inside" or inEntryMode == "Confirmation close"
        bool invalidStop = sdCompare(close, brokerInitialStop) * brokerDirection < 0
        bool invalidZone = brokerModel == "iFVG" and (brokerDirection == 1 ? sdCompare(close, brokerZoneLow) < 0 : sdCompare(close, brokerZoneHigh) > 0)
        bool missed = brokerDirection == 1 ? sdCompare(high, brokerTp1) >= 0 : sdCompare(low, brokerTp1) <= 0
        if marketMode or invalidStop or invalidZone or missed or bar_index - brokerSubmitBar >= inExpire
            strategy.cancel("Entry")
            strategy.cancel("Bracket")
            brokerState := 0
            brokerLastExitBar := bar_index
            brokerClosedThisBar := true
            brokerStatus := marketMode ? "No broker fill; inspect margin / quantity" : "Resting order cancelled by its own invalidation / expiry"
    if brokerState == 2 and brokerOccupied
        // Do not use the entry bar's pre-fill high/low to advance a broker stop.
        if bar_index > brokerFillBar
            [nextStop, one, two, newOne, newTwo] = sdMilestones(brokerDirection, brokerEntry, brokerStop, brokerTp1, brokerTp2, high, low, brokerTp1Hit, brokerTp2Hit, inBE, syminfo.mintick)
            brokerStop := nextStop
            brokerTp1Hit := one
            brokerTp2Hit := two
        strategy.exit("Bracket", from_entry = "Entry", stop = brokerStop, limit = brokerTarget, comment_profit = "TARGET", comment_loss = brokerTp2Hit ? "TRAIL" : brokerTp1Hit and inBE ? "BE" : "STOP")
        if bar_index - brokerFillBar >= inMaxTrade
            strategy.close("Entry", comment = "TIME", immediately = true)
    bool marketMode = inEntryMode == "Close inside" or inEntryMode == "Confirmation close"
    bool sharedIntent = marketMode ? ev.filled : ev.armed
    bool available = brokerState == 0 and not brokerOccupied and not brokerClosedThisBar and (na(brokerLastExitBar) or bar_index > brokerLastExitBar)
    float increment = labStepMode == "Manual" ? labStep : syminfo.mincontract
    float accountUnitRisk = math.abs(strategy.convert_to_account(s.r * syminfo.pointvalue))
    float accountUnitNotional = math.abs(strategy.convert_to_account(s.entry * syminfo.pointvalue))
    [plannedQuantity, plannedRisk, plannedNotional, sizeValid, sizeReason] = sdQuantity(labMode == "Fixed units", labFixed, labRisk, accountUnitRisk, accountUnitNotional, strategy.equity, labCap, increment)
    if sharedIntent and available and s.planValid and not sizeValid
        brokerStatus := "SKIPPED · " + sizeReason
    if sharedIntent and available and s.planValid and sizeValid
        brokerQuantity := plannedQuantity
        brokerRiskEstimate := plannedRisk
        brokerNotional := plannedNotional
        brokerStatus := "Submitted; broker fill not yet confirmed"
        brokerState := 1
        brokerDirection := marketMode ? ev.dir : s.dir
        brokerSubmitBar := bar_index
        brokerFillBar := na
        brokerEntry := s.entry
        brokerStop := s.sl0
        brokerInitialStop := s.sl0
        brokerTarget := s.tpFinal
        brokerTp1 := s.tp1
        brokerTp2 := s.tp2
        brokerZoneHigh := s.fvgTop
        brokerZoneLow := s.fvgBot
        brokerModel := s.model
        brokerTp1Hit := false
        brokerTp2Hit := false
        strategy.entry("Entry", brokerDirection == 1 ? strategy.long : strategy.short, qty = brokerQuantity, limit = marketMode ? na : brokerEntry, comment = "STRUCTURAL " + brokerModel)
        // Same-calculation bracket, including before position_size changes.
        strategy.exit("Bracket", from_entry = "Entry", stop = brokerStop, limit = brokerTarget, comment_profit = "TARGET", comment_loss = "STOP")
        brokerSubmitted := true
plot(brokerState > 0 ? brokerEntry : na, "Broker entry reference", color = color.white, style = plot.style_linebr, display = display.pane)
plot(brokerState > 0 ? brokerStop : na, "Broker owned stop", color = color.red, style = plot.style_linebr, display = display.pane)
plot(brokerState > 0 ? brokerTarget : na, "Broker owned target", color = color.lime, style = plot.style_linebr, display = display.pane)
var table brokerTable = table.new(position.top_right, 2, 7, bgcolor = color.rgb(12,18,28), frame_color = color.gray, frame_width = 1)
// Fixed seven-row operational HUD: update every confirmed calculation, including closed-feed reloads.
if barstate.isconfirmed
    table.cell(brokerTable,0,0,"SWEEP · STRATEGY LAB",text_color=color.white)
    table.cell(brokerTable,1,0,brokerState == 2 ? "BROKER OPEN" : brokerState == 1 ? "SUBMITTED" : "FLAT",text_color=color.aqua)
    table.cell(brokerTable,0,1,"Status",text_color=color.silver)
    table.cell(brokerTable,1,1,brokerStatus,text_color=color.white)
    table.cell(brokerTable,0,2,"New setups / data",text_color=color.silver,tooltip=sdViewDataHelp)
    table.cell(brokerTable,1,2,sdViewAdmissionData(),text_color=sdViewDataWait ? color.orange : color.silver,tooltip=sdViewDataHelp)
    table.cell(brokerTable,0,3,"Quantity / risk est.",text_color=color.silver)
    table.cell(brokerTable,1,3,sdNumber(brokerQuantity)+" / "+sdNumber(brokerRiskEstimate)+" "+strategy.account_currency,text_color=color.white)
    table.cell(brokerTable,0,4,"Entry / SL / target",text_color=color.silver)
    table.cell(brokerTable,1,4,brokerState > 0 ? fmt(brokerEntry)+" / "+fmt(brokerStop)+" / "+fmt(brokerTarget) : "No owned bracket",text_color=color.white)
    table.cell(brokerTable,0,5,"Technical engine",text_color=color.silver)
    table.cell(brokerTable,1,5,stateWord+" · paper paced",text_color=color.white)
    table.cell(brokerTable,0,6,"Header defaults / cap",text_color=color.silver)
    table.cell(brokerTable,1,6,"0.02% + 5 ticks; margin 100% · cap "+str.tostring(labCap)+"%",tooltip="Verify Strategy Properties: commission, slippage and margin can be overridden there. Pine cannot read those overrides.",text_color=color.white)
// ── BROKER OWNERSHIP END

// ═══════════════════════════════════════════════════════════════════ ALERTS (confirmed bars only by construction)
payload(string evName, string why) =>
    string js = '{"src":"SweepDesk","symbol":"' + sdEscape(syminfo.tickerid) + '","tf":"' + sdEscape(timeframe.period) + '","event":"' + evName + '","side":"' + (s.dir == 1 or ev.dir == 1 ? "LONG" : "SHORT") + '","model":"' + sdEscape(s.model) + '","pool":"' + sdEscape(s.poolKind) + '","entry":' + sdNumber(s.entry) + ',"sl":' + sdNumber(s.sl) + ',"initial_sl":' + sdNumber(s.sl0) + ',"tp1":' + sdNumber(s.tp1) + ',"tp2":' + sdNumber(s.tp2) + ',"tp3":' + sdNumber(s.tp3) + ',"tp_liq":' + sdNumber(s.tpLiq) + ',"target":' + sdNumber(s.tpFinal) + ',"reason":"' + sdEscape(why) + '","confluences":"' + sdEscape(s.confluenceSummary) + '","entry_time":' + sdNumber(s.entryTime) + ',"end_observed_time":' + sdNumber(s.endTime) + ',"exit_price":' + sdNumber(ev.exit ? ev.px : na) + ',"gross_r":' + sdNumber(ev.r) + '}'
    string tx = "Sweep Desk · " + syminfo.ticker + " " + timeframe.period + " · " + evName + " · " + (s.dir == 1 or ev.dir == 1 ? "LONG" : "SHORT") + " · entry " + fmt(nz(s.entry, ev.px)) + " · SL " + fmt(nz(s.sl, 0)) + " · TP " + fmt(nz(s.tp1, 0)) + " / " + fmt(nz(s.tp2, 0)) + " / " + fmt(nz(s.tp3, 0)) + (na(s.tpLiq) ? "" : " · liq " + fmt(s.tpLiq)) + (why != "" ? " · " + why : "")
    inAlertJson ? js : tx
// One envelope per confirmed update; no same-bar FILLED/EXIT or TP1/TP2 event is lost.
if barstate.isconfirmed and (ev.armed or ev.filled or ev.tp1 or ev.tp2 or ev.exit or ev.cancel)
    string kinds = ""
    if ev.armed
        kinds += "\"ARMED\""
    if ev.filled
        kinds += (kinds == "" ? "" : ",") + "\"FILLED\""
    if ev.tp1
        kinds += (kinds == "" ? "" : ",") + "\"TP1\""
    if ev.tp2
        kinds += (kinds == "" ? "" : ",") + "\"TP2\""
    if ev.exit
        kinds += (kinds == "" ? "" : ",") + "\"EXIT\""
    if ev.cancel
        kinds += (kinds == "" ? "" : ",") + "\"CANCEL\""
    string primary = ev.exit ? "EXIT" : ev.cancel ? "CANCEL" : ev.filled ? "FILLED" : ev.armed ? "ARMED" : ev.tp2 ? "TP2" : "TP1"
    string message = payload(primary, ev.why)
    if inAlertJson
        message := str.substring(message, 0, str.length(message) - 1) + ",\"schema\":\"sweep-desk.v2\",\"events\":[" + kinds + "],\"confirmed\":true,\"fill_confirmed\":false,\"costs_included\":false,\"bar_close_ms\":" + str.tostring(time_close) + ",\"plan_id\":" + (na(s.planId) ? "null" : str.tostring(s.planId)) + ",\"ambiguous\":" + (s.ambiguous ? "true" : "false") + "}"
    alert(message, alert.freq_once_per_bar_close)
Download the strategy .pine file

Release identity and file hashes · MIT licence

No setup yet?

Wait for completed history and unused first-touch context. The default 60-minute source must exceed your chart. Check New setups · data and Co-Pilot. A sweep alone cannot enter; data warnings do not replace an existing plan’s state.

Go deeper, one question at a time.

Full rules and settings, when you need them.

What happens between a sweep and an entry?
  1. WatchStart with a confirmed high or low.
  2. SweepPrice passes it, then closes back across.
  3. ConfirmA later qualifying close prepares a zone.
  4. AcceptDefault: enter at the qualifying confirmation close.

This long setup mirrors for shorts above a high. Defaults: Confirmation close and Single gaps. Close inside and limit entries are alternatives.

Where do the reference levels come from?

Swings: highs/lows confirmed five candles later by default. Equal levels: nearby highs/lows merged within an ATR tolerance. Sessions: extremes available after a session ends. Higher-timeframe swings and previous day/week levels add context.

“Liquidity pool” is the tool’s name for these price-derived references; it does not observe waiting orders. “Strong” means equal, session, higher-timeframe or day/week by type. It does not establish a better outcome.

Any wick strictly beyond a pool consumes it, even without enough depth or a close back inside. Touching exactly does not. Entry filters cannot make consumed liquidity fresh again. Age and budget prune swing-type pools; the drawing-distance setting only hides distant lines.

How do gap inversion and structure shift differ?

iFVG, the default: after a sweep, a new close through an opposing gap’s far edge can prepare that gap as the zone. In our long example, exactly 90 fails; above 90 can qualify. The gap must also meet age, location and movement checks.

Earlier candles → test candleTest close 96
Close must exceed94110101928374
Gap 1Gap 2Drag the blue close ↕
96

The gap condition confirms. At 96, the newest individual gap needs a strict close above 94. Equality does not confirm.

Original Series rule: same-direction gaps formed on consecutive chart bars, combined before inversion. This isolated illustration supplies valid gap sizes and shows only the inversion check; sweep, displacement, confluences and plan geometry remain separate requirements.

Default HTF prerequisite: a completed 60-minute FVG must receive its first valid touch. It grants six observed chart bars of directional context, ages zero through five, and cannot refresh on repeated touches. An accepted setup consumes it; a pending return retains its frozen context.

Optional SMT compares synchronized prior closed bars and needs a meaningful manually chosen pair. Momentum, a minimum observation count and confirmation-close macro windows are separate checks. Our definitions are explicit; more checks do not prove better results.

MSS, the alternative: price closes beyond the opposing short-term swing captured at the sweep, with enough movement. A qualifying gap in that shift leg becomes the zone. SHIFTED means it is still searching; ARMED means the zone is ready. Nearest picks its newest gap; Deepest picks its oldest.

Either accepts either route, with iFVG first if both qualify together. Confirmation allows twelve candles after the sweep; the next candle is too late. MSS allows eight extra candles after a shift to find the gap.

Default sweep distance is at least 0.05 ATR and the close must return strictly across the level. Confirmation needs at least 1 ATR of movement; gap width at least 0.1 ATR. ATR measures recent price movement including gaps; its length is fixed at fourteen.

Which entry mode starts the reference plan?

Confirmation close, the default: the qualifying inversion or MSS-zone acceptance close is the entry reference. Its earlier wick cannot also exit that plan.

Close inside, the alternative: a later candle closes within either edge, including exactly on an edge. Its actual close becomes entry. The confirmation candle cannot fill too.

FVG edge / CE midpoint: use a paper limit at the zone’s near edge or center. Contact can fill without a close inside. These are alternative entry rules.

The zone is fixed after arming. Close-inside entry recalculates stop and targets using the actual fill close and current ATR. The pending sweep extreme can extend before confirmation.

Why did the idea stop waiting?

Recorded reasons include no confirmation in time, no rejection, invalidation, a close through the wrong side of an inverted zone, price reaching the first target unfilled, or expiry.

A prepared setup has thirty candles to fill. Close inside first checks the frozen pending stop: a close beyond it cancels before entry and cannot move the stop to manufacture a fill. Otherwise, a qualifying fill wins over missed-target and expiry checks, including on the final allowed candle. Once expired, a later return cannot revive it.

Pro labels recorded cancellations. A fresh qualifying sweep can also replace an unarmed idea without a cancellation tag. Armed and filled setups are protected from replacement.

How do the stop and targets end the trade?

1R is initial entry-to-stop distance. Default stop: sweep extreme plus 0.1 ATR buffer. Zone far edge is an alternative. Auto can choose a nearer anchor when the sweep is too far; its 2.5 ATR cap does not constrain the default Sweep extreme setting.

Targets default to 1R, 2R and 3R. TP1 optionally moves stop to entry; TP2 always moves it to TP1. Neither takes partial profit. Protective stop changes apply from the next bar, after the current bar is checked against its existing stop. Breakeven names an entry-price reference; costs can still leave a loss. This tool has no continuous ATR trail.

Liquidity selects the nearest qualifying fresh opposing pool, skipping closer pools below the minimum distance: 2R by default, checked after rounding toward entry. Clustered quality requires at least two confirmed merged swing observations. Choose Liquidity or TP3 as final; Liquidity falls back to TP3 when none qualifies. The selected final target freezes at acceptance; later pool changes cannot move it.

Close entries have no entry-bar outcomes. On later bars, a stop-gap open is the exit reference; a favorable gap beyond the target uses the fixed target. Ordinary both-touch bars resolve stop-first and disclose ambiguity. Unknown intrabar limit fills never invent a same-bar target success. These paper assumptions are separate from broker fills. Default timeout is two hundred candles.

How do I read the dashboard and Co-Pilot?

State shows the stage and timers; Pool / Confirm / Zone identify the setup. Entry / Stop / R ladder / Liquidity describe its plan. Record / By pool / By model summarize closed paper outcomes without costs.

  1. SweptSelected qualifying sweep ideas.
  2. ConfirmedIdeas reaching confirmation.
  3. ArmedPrepared entry zones.
  4. FilledRecorded paper entries.

These counters exclude sweeps blocked by enabled filters and new sweeps while armed or filled. Sweep quality is descriptive; it gates nothing.

Co-Pilot explains NOW, WHY and PLAN from the same engine. Pro adds pool, sweep, structure, zone and cancellation labels; Full adds session shading. It does not draw every stored gap.

Actual Sweep Desk v2.0 on gold 5m in Series and Full view, with scanning status, marked reference pools and the Standard Co-Pilot.
Gold 5m · Series mode · Full view · four closed paper references, labeled too fewOpen full-size chart ↗
What does each setting change?

Four display defaults differ, labelled Indicator and Lab. The Lab has its own broker dashboard; other defaults are shared.

Reference levels

Confirmed highs/lows provide the references. Pool age and budget affect available levels; drawing distance only changes visibility.

Swing confirmation length
5 candles
Equal high/low merge distance
ATR × 0.15
Completed session highs/lows
On
Previous day’s high/low
On
Previous week’s high/low
On
Swing-type levels kept per side
10
Maximum swing-type level age
400 candles
Higher-timeframe swings
On
Higher timeframe
60 minutes
Higher-timeframe swing length
5
Draw levels within this distance
ATR × 8

Sweep checks

A wick must pass a level and close back across it. Optional checks select which sweeps can start an idea.

Minimum distance beyond level
ATR × 0.05
Require extra volume
Off
Minimum volume versus average
1.3 ×
Sweeps only in session windows
Off
Allowed level types
Any pool
Allowed direction
Both

Confirmation and zone

Choose gap inversion, structure shift or Either. Nearest/Deepest selects the newest/oldest qualifying MSS gap; it does not change iFVG selection.

Confirmation method
FVG inversion (iFVG)
Lookback before sweep for a gap
40 candles
Structure-confirmation swing length
3
Candles allowed for confirmation
12
Minimum push from sweep extreme
ATR × 1
Minimum gap size
ATR × 0.10
MSS gap choice
Nearest
MSS: extra candles to find gap
8

Entry, stop and targets

Choose the entry rule, stop anchor and final target. R means the initial entry-to-stop distance. Milestones move the stop without taking partial profits.

When an entry is accepted
Confirmation close
Initial stop anchor
Sweep extreme
Auto stop: maximum sweep distance
ATR × 2.5
Space beyond stop anchor
ATR × 0.10
First milestone
1R
Second milestone
2R
Final R target
3R
Minimum opposing-level distance
2R
Target that ends the trade
R ladder (TP3)
At TP1, move stop to entry
On
Candles allowed while unfilled
30
Maximum candles in an open trade
200

Session windows

These supply session highs/lows and the optional sweep-time check. They do not force trades to close.

Timezone
America/New_York
London window
02:00–05:00
New York morning
08:30–11:00
New York afternoon
13:30–16:00

Chart appearance

Choose Minimal, Clean, Pro or Full; Aurora Neon, Royal Gold or Colorblind-safe. Appearance does not change entry rules.

Amount of detail
Indicator: CleanLab: Minimal
Color theme
Aurora Neon
Shade risk and reward
On
Color candles during open trade
Indicator: OnLab: Off
Highlight stronger level types
On
Label distance to the right
20 candles

Dashboard

Show and arrange the current setup, counters and paper record.

Show dashboard
Indicator: OnLab: Off
Position
Top Right
Text size
Medium
Alternate row shading
On

Co-Pilot

Brief shows NOW; Standard adds WHY and PLAN; Detailed adds the record. Off hides the explanation.

Amount of explanation
Indicator: StandardLab: Off
Position
Bottom Left
Text size
Auto
Line width
64 characters

Alerts

Choose structured JSON or readable text for dynamic messages.

Send dynamic messages as JSON
On

Inversion grouping and context

Series combines adjacent gap births before inversion. Clustered target quality requires at least two confirmed merged swings; Any fresh pool also permits single observations. These are original definitions of public concepts.

iFVG mode
Single
Confirmation-close macro window
Off
Opposing target quality
Any fresh pool

Explicit confluences

The default requires one unused HTF first-touch context. Each other enabled gate must pass even when minimum votes is zero. Votes are observations, not a probability or private grade.

Require first-touch HTF FVG delivery
On
HTF FVG source
60 minutes · must exceed chart
First-touch context lifetime
6 observed chart bars
Untouched gap lifetime
50 HTF periods
Require paired-market SMT
Off
Comparison symbol
BINANCE:BTCUSDT · choose a meaningful pair
Inverse relationship
Off
Aligned-bar SMT window
10 bars
SMT context lifetime
6 chart bars
Require directional momentum
Off
Minimum body size
ATR × 0.5
Directional close location
0.7
Minimum observed confluences
0 of 4

Strategy Lab only

Lab sizing
Cash risk
Cash risk in account currency
100
Fixed units alternative
1
Maximum notional versus current equity
90 percent
Quantity increment
Symbol minimum
Manual quantity increment alternative
1

When required, volume must be positive with a positive twenty-bar mean. Zero or unavailable volume does not pass. The separate observation count is descriptive, not a private grade or probability.

Which events can send an alert?

The indicator has twelve named conditions: long/short armed, long/short filled, TP1, TP2, final target, stopped, cancelled, liquidity swept, FVG inverted and higher-timeframe pool swept. Final target covers TP3 or liquidity.

Any alert() function call sends only ARMED, FILLED, TP1, TP2, EXIT and CANCEL. It does not send separate sweep or inversion payloads. Use Once Per Bar Close.

Both scripts send JSON or readable text. The sweep-desk.v2 schema combines same-bar paper events with entry, initial/current stop, target, times and observed exit. These are not broker fills. The twin submits its bracket with entry and owns its position and deadline; a paper EXIT does not close it. Default Confirmation close uses on-close processing with declared slippage. Recreate alerts after source or setting changes.

How do I test the rules on my chart?
  1. Keep defaults and follow the state through a complete session.
  2. Include waiting, cancelled and losing examples.
  3. Load the twin on a second chart. Set realistic size, costs and slippage.
  4. Change one input at a time, then check a separate period.

Lab defaults: USD 100,000 capital, cash risk 100, maximum notional 90 percent of equity, commission 0.02 percent per order and five ticks of slippage. Fixed units is an alternative. The broker emulator can differ from the indicator’s cost-free reference ledger. Review Properties and quantity increment for the instrument.

Use the current Lab and its complete trade list. The older Strategy Tester picture belongs to the historical v1 article and is not a result for this release.

What did our measurements show?

The current v2 MNQ Strategy Lab sample lost money after costs. That small observation does not establish an edge.

The linked gold and bitcoin measurements belong to the historical v1 engine. Its close-inside baseline is not evidence for current defaults; v2 revises entry timing and broker ownership.

Keep historical findings and current validation separate. Software checks verify behavior, not economic edge.

Read the lab notes
What can change the reading?

Signals commit on closed candles. A live candle can change before its close. Different history, feeds, timeframes or inputs can recalculate results. Pivots appear after confirmation; session pools after their session ends. Higher-timeframe context uses its last closed candle.

The engine sees prices, not news, resting orders or execution. Its paper record excludes costs. It does not predict prices. Trading risk is yours; this is research tooling, not financial advice.

  • IFVG Desk

    Study fair value gap inversion with a separate indicator focused on that setup.

  • Wyckoff Evidence Desk

    Compare a single sweep sequence with parallel range hypotheses and contested evidence.

Built by ProEA Lab. Compare all free indicators.

05 · THE LOG

Every change, dated

What shipped and what changed in Sweep Desk, newest first. Ask for the next change below; what gets built lands here with a date, and on the lab queue on the front page.

  1. The confirming close starts the plan v2.0.0 Updated Sweep Desk

    Default inversion-close entries, explicit context checks and an independent broker lifecycle replace the earlier execution model.

    • Single and consecutive Series gaps, with optional return-entry modes retained
    • First-touch completed HTF context and separate setup-data readiness
    • No earlier entry-candle wick exits, plus explicit later gap and both-touch policies
    • Separate broker brackets, occupancy and exits; historical v1 measurements stay labeled
  2. Sweep Desk v1.1.0 Shipped

    Mapped a sweep, confirmation and return entry to stop and target references.

  3. Entries and exits around the sweep Request Sweep Desk

    The ask Sweep Desk was built from. Shipped as v1.1.0 with the inversion-gap confirmation, the stop at the sweep extreme and both targets drawn.

    1. Received
    2. Building
    3. Done

    Finding the liquidity is easy. Getting the actual entries and exits right is where the gold is.

    a liquidity trader, on the r/pinescript thread · Asked 3 Sep 2026 · Done 3 Sep 2026
  4. Measured on two markets before it shipped Lab note Sweep Desk

    Run with costs on across two markets. Only the inversion-gap confirmation held on both; the notes name where it barely paid.

BUILT WITH TRADERS

Something to change in Sweep Desk?

A small friction or a big idea. Tell us what would make this tool work better for you.

What would help?

Describe your rules and what you want the tool to help you see.

Add chart details Optional

For a question about your idea or an update if it ships.

Guides & new tools Optional

Add an email above to choose this.

Show us on your chart Optional

Crop out any account details you don't want to share.

No account needed. Sent privately to the lab.
  1. We read it
  2. We choose what to build
  3. Shipped changes get a date

Requests for a new tool go on the same queue: see what is asked for and what is being built.

© 2026 ProEA · All rights reservedBuilt for MT5 · Not affiliated with MetaQuotes