IFVG Desk v1.2.0 · Free & open-source

What changes when a gap flips?

See the gap form, flip and pass or fail its checks.

Indicator + strategy companion · MIT · No signup

ONE GAP, FOLLOWED THROUGHIllustrated example
Gap → close through → checksArmed. Entry still needs its rule.
  1. 01Find the gap
  2. 02See it flip
  3. 03Read the checks

What is IFVG Desk?

IFVG Desk v1.2 is a free TradingView indicator for inversion fair value gaps. It checks an entry at a confirmed candle close, then keeps fixed paper-plan levels. A separate event history saves original decision reasons under each Setup ID. Recording starts on; the history panel starts hidden. Its bounded paper-engine journal is rebuilt on recalculation and is separate from broker fills.

Does an inverted fair value gap automatically create a trade?

No. An armed gap still needs the selected entry trigger, enabled context checks, usable data and valid risk geometry. The daily allowance also applies. In inversion-close mode, a failed flip-bar entry is not queued for a later candle. Read why an inversion may wait

Which filters can qualify an IFVG entry?

The six checks cover higher-timeframe gap overlap, a recent sweep, SMT comparison, previous-day direction, entry displacement and the New York session. Only the session gate is required by default; enabled checks need valid data before an entry qualifies. Explore the six context checks

How do I trace one setup’s original decisions?

Enable Show setup history and filter with the full Setup ID from a row tooltip. Eight events appear per page. Recording defaults to 300 events across all setups, up to 500; filtering does not reserve storage. Older decisions may be removed. Recalculation rebuilds the journal; Pine Logs export requires an editable personal copy. Read the history and export limits

First see the gap. Then see it fail.

Mark a three-candle gap, then watch for a closing break.

Watch the outer candlesClosed candle 1
Start with this candle’s lowest price.Its thin wick reaches 106. The thick body joins the open and close. We need two more candles before a gap can exist. Green candles close above their open; red candles close below. White dot: latest close. No gap has formed yet.
Thin wick: price extremes · White dot: latest close
Close
108
Original zone
not born
Paper plan
none
STEP 1 / 5

Start with this candle’s lowest price.

Its thin wick reaches 106. The thick body joins the open and close. We need two more candles before a gap can exist.

Fictional candles and supplied earlier history. Retest mode shows the flip separately from entry.

How could it help a trade?

It makes entry, blockers and exits testable. Clear rules alone do not establish an advantage after costs.

Same gap. Different next steps.

Reveal candle four, then compare the choices.

Watch the outer candlesClosed candle 1
The gap does not exist yet.Reveal candle three. The first and third candles must leave a strict space between their wicks; the middle candle may trade through it. Green candles close above their open; red candles close below. White dot: latest close. No gap has formed yet.
Thin wick: price extremes · White dot: latest close
Close
108
Original zone
not born
Paper plan
none
CANDLE 1 / 6

The gap does not exist yet.

Reveal candle three. The first and third candles must leave a strict space between their wicks; the middle candle may trade through it.

A flip, an accepted plan and a trade result are different events.

What is supplied in these examples?

Thirty earlier fictional candles warm up ATR 14 and the prior ten-bar stop. They recalculate with revealed prices. All shown bars are closed and inside one New York entry session; there is no earlier plan. The three supplied context gaps remain fixed: bearish 5m at 102–105, bullish 15m at 110–114 and bullish 30m at 118–122. Previous-day direction is bullish; synchronized SMT is unavailable.

Tick 0.25, cash risk 100 in symbol currency, point value 1, quantity step 1, one-tick stop buffer and 1R target are illustrative inputs. Only the session check is required, except the blocked case also requires higher-frame overlap. No future bars, account conversion, fees or broker fills are inferred.

Find the same story on your chart.

Read state → reason → plan. ARMED means an inverted gap; entry still needs its checks.

New in v1.2: Follow a Setup ID to its saved entry or skip reason. Open the history guide ↓

Earlier v1.1.1 capture · Gold · 5m · 15 / 30 / 60m lanes · Aurora · Compact dashboard

Original IFVG Desk v1.1.1 gold 5-minute chart: DAY DONE, required session check, higher-timeframe alignment rows, armed IFVG, session range and Co-Pilot.

Read DAY DONE and its reason first. The session check is required; the other checks are optional. An armed zone can remain on the chart after the daily plan allowance is used. The merged lane tag separates aligned bearish context from the opposing bullish lane.

Open the original capture ↗
Explore every control in the full simulator
CHANGE THE CANDLE. SEE WHY.

Can this close flip the gap?

Drag the last candle’s close across the blue edge. A wick is insufficient. The closed price decides whether the bearish gap becomes a bullish IFVG.

Earlier candles → test candleFlip-test close 108
Plan target121.25Original upper106Original bottom100Plan stop94.75142126.511195.580
Original gap · usedEntry at this close 108Drag the blue close ↕

Only the flip-test candle is edited. The later return is not shown yet.

Watch the same candle change
108
How the calculation works & more controls

The session check is required and met. Other untouched gates are off. Current context overlap is not met; entry displacement is met against 0.5 × ATR 6.69. Making an unmet check required blocks the trigger.

Supplied context: 5m bearish 102–105; 15m bullish 110–114; 30m bullish 118–122. The gate searches every aligned gap. It does not merely read the nearest displayed lane. These ranges are not recalculated from the small chart.

Retest requires a later overlapping bar and a strict close above both the upper edge and its own open. A failed retest may qualify on a later bar while still armed. Inversion-close mode cannot accept a previously blocked flip later. Invalid, used and expired zones never reopen.

Tick 0.25, prior ten-bar swing plus one tick, target 1R, cash risk 100, point value 1 and quantity step 1 are illustrative sizing inputs. Entry-bar extremes never resolve a new plan. No commissions, broker fills or future bars are simulated.

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

Paste it into TradingView.

Two complete files. No signup needed.

  1. 1

    Open Pine Editor

    Use standard intraday candles. Start with 1m and Auto context lanes.

  2. 2

    Paste the indicator

    Replace the sample code, save and add to chart.

  3. 3

    Check the clock

    The entry session defaults to 09:30–11:00 New York.

The indicator

Indicator code

Copy the code as it is. No coding needed.

View the full code
//@version=6
// Web revision display-fix.1: restore precise inversion-close and positive-close wording; no engine changes.
// IFVG Desk · original open-source implementation · MIT
// Built from Pine Builder skeleton architecture. See docs/build-card.md.
// Signals are close-confirmed. Paper R excludes costs; broker simulation differs.
indicator("IFVG Desk · Open Source", "IFVG Desk", overlay = true, behind_chart = false, max_boxes_count = 160, max_lines_count = 350, max_labels_count = 200, max_bars_back = 3000)
const bool IS_STRATEGY = false

// Original, mechanical interpretation of publicly described IFVG concepts.
// CLOSED-BAR SPINE: every lifecycle, signal, and paper mutation is confirmed-only.
const string NY_TZ = "America/New_York"
const string G_ENGINE = "01 · Gap engine"
const string G_CONTEXT = "02 · Higher-timeframe context"
const string G_CHECKS = "03 · Confluence gates"
const string G_RISK = "04 · Risk & paper record"
const string AUDIT_VERSION = "1.2.0"
const string G_AUDIT = "06 · Setup audit trail"

string entryMode = input.string("Inversion close", "Entry trigger", options = ["Inversion close", "Retest rejection"], group = G_ENGINE, tooltip = "Inversion: later close through opposite gap edge. Retest: a subsequent overlapping candle rejects beyond the IFVG edge in the trade direction. Failed gates do not create retroactive entries.", display = display.none)
int atrLen = input.int(14, "ATR length", minval = 2, group = G_ENGINE, display = display.none)
float minGapAtr = input.float(0.05, "Minimum gap / ATR", minval = 0, maxval = 3, step = 0.05, group = G_ENGINE, display = display.none)
float birthBodyAtr = input.float(0, "Middle candle body / ATR", minval = 0, maxval = 5, step = 0.1, group = G_ENGINE, tooltip = "Zero disables the gap-birth displacement filter. Uses the middle candle's confirmed ATR.", display = display.none)
int maxZoneAge = input.int(180, "Maximum zone age · chart bars", minval = 5, maxval = 2000, group = G_ENGINE, display = display.none)
int zoneCap = input.int(40, "Retained chart zones", minval = 5, maxval = 80, group = G_ENGINE, display = display.none)

string tfInputA = input.string("Auto", "Lane A timeframe", options = ["Auto", "1", "3", "5", "15", "30", "60", "120", "240", "D", "W"], group = G_CONTEXT, display = display.none)
string tfInputB = input.string("Auto", "Lane B timeframe", options = ["Auto", "1", "3", "5", "15", "30", "60", "120", "240", "D", "W"], group = G_CONTEXT, display = display.none)
string tfInputC = input.string("Auto", "Lane C timeframe", options = ["Auto", "1", "3", "5", "15", "30", "60", "120", "240", "D", "W"], group = G_CONTEXT, tooltip = "Auto resolves to 5/15/30 on 1m and 3m; 15/30/60 on 5m. All three lanes must be strictly increasing and higher than chart, including disabled lanes.", display = display.none)
bool laneOnA = input.bool(true, "Enable lane A", group = G_CONTEXT, display = display.none)
bool laneOnB = input.bool(true, "Enable lane B", group = G_CONTEXT, display = display.none)
bool laneOnC = input.bool(true, "Enable lane C", group = G_CONTEXT, display = display.none)
int htfMaxAge = input.int(80, "Context age · source bars", minval = 3, maxval = 500, group = G_CONTEXT, display = display.none)
bool htfGate = input.bool(false, "Require aligned HTF gap overlap", group = G_CHECKS, tooltip = "Our definition: signal candle overlaps a still-valid same-direction FVG in any enabled higher-timeframe lane. A confluence count is not a probability.", display = display.none)
bool sweepGate = input.bool(false, "Require recent liquidity sweep", group = G_CHECKS, display = display.none)
int sweepLookback = input.int(20, "Sweep reference · prior bars", minval = 2, maxval = 200, group = G_CHECKS, display = display.none)
int sweepWindow = input.int(10, "Sweep remains recent · bars", minval = 0, maxval = 100, group = G_CHECKS, display = display.none)
bool displacementGate = input.bool(false, "Require entry candle displacement", group = G_CHECKS, display = display.none)
float displacementAtr = input.float(0.5, "Entry body / ATR", minval = 0.1, maxval = 5, step = 0.1, group = G_CHECKS, display = display.none)
bool biasGate = input.bool(false, "Require prior-day candle bias", group = G_CHECKS, display = display.none)
bool smtGate = input.bool(false, "Require synchronized SMT", group = G_CHECKS, display = display.none)
string smtSymbol = input.symbol("CME_MINI:ES1!", "SMT comparison symbol", group = G_CHECKS, display = display.none)
string smtRelationship = input.string("Direct", "SMT relationship", options = ["Direct", "Inverse"], group = G_CHECKS, display = display.none)
int smtLookback = input.int(10, "SMT prior-bar reference length", minval = 2, maxval = 100, group = G_CHECKS, tooltip = "Chart prior bar makes a new extreme versus its previous N bars; synchronized comparison prior bar does not confirm. Inverse relationship swaps comparator high/low. Missing or stale data never passes.", display = display.none)
bool sessionGate = input.bool(true, "Require NY entry session", group = G_CHECKS, display = display.none)
string tradeSession = input.session("0930-1100", "Entry session · New York", group = G_CHECKS, display = display.none)

float cashRisk = input.float(100, "Planned cash risk · symbol currency", minval = 1, maxval = 1000000, group = G_RISK, display = display.none)
float rewardR = input.float(1, "Target / initial risk", minval = 0.25, maxval = 10, step = 0.25, group = G_RISK, display = display.none)
int swingLookback = input.int(10, "Stop swing · prior bars", minval = 2, maxval = 200, group = G_RISK, display = display.none)
int stopTicks = input.int(1, "Stop buffer · ticks", minval = 0, maxval = 100, group = G_RISK, display = display.none)
float qtyStep = input.float(1, "Quantity step", minval = 0.000001, group = G_RISK, tooltip = "Quantity = floor(cash risk / (stop distance × point value) / step) × step. A zero estimate blocks entries. Uses symbol currency; no FX conversion. Fractional contracts may not be executable.", display = display.none)
int maxTradesDay = input.int(1, "Resolved paper trades / NY day", minval = 1, maxval = 100, group = G_RISK, display = display.none)
int maxHoldBars = input.int(120, "Timeout · bars after entry", minval = 1, maxval = 5000, group = G_RISK, display = display.none)
bool closeAtSessionEnd = input.bool(false, "Close on first bar outside session", group = G_RISK, display = display.none)
bool sendJsonAlerts = input.bool(true, "Send structured entry alerts", group = "05 · Alerts", tooltip = "Create a TradingView alert using Any alert() function call. Paper plans, not broker execution instructions. One confirmed entry per signal; payload includes deterministic symbol/frame/time/zone ID.", display = display.none)
bool auditEnabled = input.bool(true, "Record setup history", group = G_AUDIT, tooltip = "Records confirmed gap, inversion, retest, entry decision and terminal events as frozen snapshots. Independent of display settings. Recalculating rebuilds history from the loaded bars and current settings.", display = display.none)
int auditCap = input.int(300, "Retained audit events", minval = 50, maxval = 500, group = G_AUDIT, tooltip = "Rolling event limit across all setups. Old events can be removed; the history panel and report say how many. This is not a permanent archive.", display = display.none)
string auditSetupFilter = input.string("", "Setup ID filter · blank = all", group = G_AUDIT, tooltip = "Paste the full Setup ID from a history-row tooltip or Pine Logs. Filters the panel and report, not recording or trading decisions.", display = display.none)
bool auditLogExport = input.bool(false, "Write bug report to Pine Logs", group = G_AUDIT, tooltip = "For an editable personal copy: writes settings and retained filtered snapshots after historical calculation, then matching confirmed realtime events. Copy the JSON lines from Pine Logs. Published scripts do not expose Pine Logs.", display = display.none)

type Zone
    int id
    int dir
    int phase
    float hi
    float lo
    int bornBar
    int bornTime
    int flipBar
    int flipTime
    int endTime

type HGap
    int dir
    float hi
    float lo
    int age

type Event
    int stamp
    string message
    int dir

// Value-only records: no Zone/HGap references can rewrite a recorded explanation.
type AuditEvent
    int stamp
    string setupId
    string kind
    string reason
    int dir
    string payload
    string summary

var array<Zone> zones = array.new<Zone>()
var array<HGap> laneA = array.new<HGap>()
var array<HGap> laneB = array.new<HGap>()
var array<HGap> laneC = array.new<HGap>()
var array<Event> eventTape = array.new<Event>()
var array<AuditEvent> auditTrail = array.new<AuditEvent>()
var array<int> auditDropped = array.new<int>(1, 0)

f_event(string msg, int direction) =>
    array.unshift(eventTape, Event.new(time_close, msg, direction))
    if array.size(eventTape) > 6
        array.pop(eventTape)

f_nextTf(float seconds) =>
    seconds < 300 ? "5" : seconds < 900 ? "15" : seconds < 1800 ? "30" : seconds < 3600 ? "60" : seconds < 7200 ? "120" : seconds < 14400 ? "240" : seconds < 86400 ? "D" : seconds < 604800 ? "W" : "M"

f_tickOut(float px, int direction, bool target) =>
    int roundingDirection = target ? direction : -direction
    float ticks = px / syminfo.mintick
    math.round_to_mintick((roundingDirection == 1 ? math.ceil(ticks - 0.000000001) : math.floor(ticks + 0.000000001)) * syminfo.mintick)

// Some feeds publish sub-tick OHLC values. Preserve them; snap only arithmetic noise.
f_priceDistance(float fromPrice, float toPrice, int direction) =>
    float rawDistance = (toPrice - fromPrice) * direction
    float rawTicks = rawDistance / syminfo.mintick
    float nearestTicks = math.round(rawTicks)
    float tickTolerance = 8 * 2.220446049250313e-16 * math.max(1, math.max(math.abs(fromPrice), math.abs(toPrice)) / syminfo.mintick)
    bool snapNoise = math.abs(rawTicks - nearestTicks) / tickTolerance <= 1
    float distance = snapNoise ? nearestTicks * syminfo.mintick : rawDistance
    float ticks = snapNoise ? nearestTicks : rawTicks
    [distance, ticks]

// Round only machine-level noise at a quantity-step boundary, never an actual shortfall.
f_riskQuantity(float distance) =>
    float qty = 0
    if not na(distance) and distance > 0 and syminfo.pointvalue > 0
        float unitRisk = distance * syminfo.pointvalue
        float rawSteps = cashRisk / unitRisk / qtyStep
        float nearestSteps = math.round(rawSteps)
        float stepTolerance = 8 * 2.220446049250313e-16 * math.max(1, math.abs(rawSteps))
        // Pine rounds float comparison operands to 9 decimals; compare scaled errors.
        float normalizedSteps = math.abs(rawSteps - nearestSteps) / stepTolerance <= 1 ? nearestSteps : rawSteps
        float wholeSteps = math.floor(normalizedSteps)
        qty := wholeSteps * qtyStep
        float totalRisk = qty * unitRisk
        float budgetTolerance = 8 * 2.220446049250313e-16 * math.max(math.abs(cashRisk), math.abs(totalRisk))
        if (totalRisk - cashRisk) / budgetTolerance > 1
            qty := math.max(0, wholeSteps - 1) * qtyStep
    qty

string tfA = tfInputA == "Auto" ? f_nextTf(timeframe.in_seconds()) : tfInputA
string tfB = tfInputB == "Auto" ? f_nextTf(timeframe.in_seconds(tfA)) : tfInputB
string tfC = tfInputC == "Auto" ? f_nextTf(timeframe.in_seconds(tfB)) : tfInputC
if barstate.isfirst
    if not chart.is_standard or not timeframe.isintraday
        runtime.error("IFVG Desk needs standard time-based intraday candles. Start with 1m, 3m or 5m.")
    if timeframe.in_seconds(tfA) <= timeframe.in_seconds() or timeframe.in_seconds(tfB) <= timeframe.in_seconds(tfA) or timeframe.in_seconds(tfC) <= timeframe.in_seconds(tfB)
        runtime.error("Context frames must satisfy chart < A < B < C. Choose Auto in all three lanes, or increase the manual frames.")
    if htfGate and not (laneOnA or laneOnB or laneOnC)
        runtime.error("The HTF gate needs at least one enabled lane. Enable a lane or turn off the HTF gate.")

// Every tuple member is shifted at source: no developing HTF values or backfill.
[stampA, closeA, highA, lowA, oldHighA, oldLowA, atrA, bodyA, middleAtrA] = request.security(syminfo.tickerid, tfA, [time[1], close[1], high[1], low[1], high[3], low[3], ta.atr(atrLen)[1], math.abs(close[2] - open[2]), ta.atr(atrLen)[2]], lookahead = barmerge.lookahead_on)
[stampB, closeB, highB, lowB, oldHighB, oldLowB, atrB, bodyB, middleAtrB] = request.security(syminfo.tickerid, tfB, [time[1], close[1], high[1], low[1], high[3], low[3], ta.atr(atrLen)[1], math.abs(close[2] - open[2]), ta.atr(atrLen)[2]], lookahead = barmerge.lookahead_on)
[stampC, closeC, highC, lowC, oldHighC, oldLowC, atrC, bodyC, middleAtrC] = request.security(syminfo.tickerid, tfC, [time[1], close[1], high[1], low[1], high[3], low[3], ta.atr(atrLen)[1], math.abs(close[2] - open[2]), ta.atr(atrLen)[2]], lookahead = barmerge.lookahead_on)
[pdh, pdl, pdOpen, pdClose] = request.security(syminfo.tickerid, "D", [high[1], low[1], open[1], close[1]], lookahead = barmerge.lookahead_on)
[smtStamp, smtHigh, smtLow, smtPriorHigh, smtPriorLow, smtOldestHigh, smtOldestLow] = request.security(smtSymbol, timeframe.period, [time[1], high[1], low[1], ta.highest(high[2], smtLookback), ta.lowest(low[2], smtLookback), high[smtLookback + 1], low[smtLookback + 1]], gaps = barmerge.gaps_on, lookahead = barmerge.lookahead_on, ignore_invalid_symbol = true)

float atrValue = ta.atr(atrLen)
// Read history every calculation before conditional snapshot calls.
float auditMiddleBody = math.abs(close[1] - open[1])
float auditMiddleAtr = atrValue[1]
float auditOldHigh = high[2]
float auditOldLow = low[2]
float auditPriorBarHigh = high[1]
float auditPriorBarLow = low[1]
float sweepPriorLow = ta.lowest(low, sweepLookback)[1]
float sweepPriorHigh = ta.highest(high, sweepLookback)[1]
bool sweepBullNow = not na(low[sweepLookback]) and low < sweepPriorLow and close > sweepPriorLow
bool sweepBearNow = not na(high[sweepLookback]) and high > sweepPriorHigh and close < sweepPriorHigh
int barsSinceBullSweep = ta.barssince(sweepBullNow)
int barsSinceBearSweep = ta.barssince(sweepBearNow)
bool recentBullSweep = not na(barsSinceBullSweep) and barsSinceBullSweep <= sweepWindow
bool recentBearSweep = not na(barsSinceBearSweep) and barsSinceBearSweep <= sweepWindow
float ownPriorHigh = ta.highest(high[2], smtLookback)
float ownPriorLow = ta.lowest(low[2], smtLookback)
float swingLow = ta.lowest(low, swingLookback)[1]
float swingHigh = ta.highest(high, swingLookback)[1]
bool smtAvailableNow = not na(smtStamp) and smtStamp == time[1] and not na(smtHigh) and not na(smtLow) and not na(smtOldestHigh) and not na(smtOldestLow) and not na(high[smtLookback + 1]) and not na(low[smtLookback + 1]) and not na(smtPriorHigh) and not na(smtPriorLow) and not na(ownPriorHigh) and not na(ownPriorLow)
bool smtBullNow = smtAvailableNow and low[1] < ownPriorLow and (smtRelationship == "Direct" ? smtLow >= smtPriorLow : smtHigh <= smtPriorHigh)
bool smtBearNow = smtAvailableNow and high[1] > ownPriorHigh and (smtRelationship == "Direct" ? smtHigh <= smtPriorHigh : smtLow >= smtPriorLow)
int pdBias = pdClose > pdOpen ? 1 : pdClose < pdOpen ? -1 : 0
bool inSession = not na(time(timeframe.period, tradeSession, NY_TZ))
// Anchor each session to its scheduled NY start, even when the feed omits outside bars.
int sessionStartHour = int(str.tonumber(str.substring(tradeSession, 0, 2)))
int sessionStartMinute = int(str.tonumber(str.substring(tradeSession, 2, 4)))
int minutesIntoNyDay = hour(time, NY_TZ) * 60 + minute(time, NY_TZ)
int sessionAnchorDay = dayofmonth(time, NY_TZ) - (minutesIntoNyDay < sessionStartHour * 60 + sessionStartMinute ? 1 : 0)
int sessionInstanceStamp = timestamp(NY_TZ, year(time, NY_TZ), month(time, NY_TZ), sessionAnchorDay, sessionStartHour, sessionStartMinute)
bool sessionStart = inSession and (not inSession[1] or sessionInstanceStamp != sessionInstanceStamp[1])
bool sessionEnd = not inSession and inSession[1]
int nyDay = year(time_close, NY_TZ) * 10000 + month(time_close, NY_TZ) * 100 + dayofmonth(time_close, NY_TZ)
bool newNyDay = bar_index == 0 or nyDay != nyDay[1]
bool openBar = hour(time, NY_TZ) == 9 and minute(time, NY_TZ) == 30

f_updateLane(array<HGap> lane, float sourceClose, float sourceHigh, float sourceLow, float oldHigh, float oldLow, float sourceAtr, float middleBody, float middleAtr) =>
    int i = array.size(lane) - 1
    while i >= 0
        HGap g = array.get(lane, i)
        g.age += 1
        bool broken = g.dir == 1 ? sourceClose < g.lo : sourceClose > g.hi
        if broken or g.age > htfMaxAge
            array.remove(lane, i)
        i -= 1
    bool bodyOk = birthBodyAtr == 0 or middleBody >= middleAtr * birthBodyAtr
    bool bull = sourceLow > oldHigh and (minGapAtr == 0 or sourceLow - oldHigh >= sourceAtr * minGapAtr) and bodyOk
    bool bear = sourceHigh < oldLow and (minGapAtr == 0 or oldLow - sourceHigh >= sourceAtr * minGapAtr) and bodyOk
    if bull or bear
        array.push(lane, HGap.new(bull ? 1 : -1, bull ? sourceLow : oldLow, bull ? oldHigh : sourceHigh, 0))
    if array.size(lane) > 12
        array.shift(lane)

f_overlap(array<HGap> lane, int direction) =>
    bool found = false
    if array.size(lane) > 0
        for i = 0 to array.size(lane) - 1
            HGap g = array.get(lane, i)
            if g.dir == direction and high >= g.lo and low <= g.hi
                found := true
    found

f_context(int direction) =>
    bool a = f_overlap(laneA, direction)
    bool b = f_overlap(laneB, direction)
    bool c = f_overlap(laneC, direction)
    direction != 0 and ((laneOnA and a) or (laneOnB and b) or (laneOnC and c))

f_laneSummary(array<HGap> lane, bool enabled) =>
    int direction = 0
    float hi = na
    float lo = na
    float distance = na
    string state = enabled ? "EMPTY" : "OFF"
    float closedPrice = barstate.isconfirmed ? close : close[1]
    if enabled and array.size(lane) > 0
        for i = 0 to array.size(lane) - 1
            HGap g = array.get(lane, i)
            float dist = closedPrice > g.hi ? closedPrice - g.hi : closedPrice < g.lo ? g.lo - closedPrice : 0
            if na(distance) or dist <= distance
                distance := dist
                direction := g.dir
                hi := g.hi
                lo := g.lo
        state := distance == 0 ? "IN GAP" : "NEARBY"
    [direction, hi, lo, distance, state]

f_gates(int direction) =>
    bool contextOk = f_context(direction)
    bool sweepOk = direction == 1 ? recentBullSweep : direction == -1 ? recentBearSweep : false
    bool smtOk = direction == 1 ? smtBullNow : direction == -1 ? smtBearNow : false
    bool biasOk = direction != 0 and pdBias == direction
    bool displacementOk = math.abs(close - open) >= atrValue * displacementAtr and (direction == 1 ? close > open : direction == -1 ? close < open : false)
    bool allowed = (not htfGate or contextOk) and (not sweepGate or sweepOk) and (not smtGate or smtOk) and (not biasGate or biasOk) and (not displacementGate or displacementOk) and (not sessionGate or inSession)
    string reason = sessionGate and not inSession ? "Outside NY entry session" : htfGate and not contextOk ? "Need aligned HTF overlap" : sweepGate and not sweepOk ? "Need recent liquidity sweep" : smtGate and not smtOk ? (smtAvailableNow ? "Need SMT divergence" : "SMT unavailable / stale") : biasGate and not biasOk ? "Prior-day bias disagrees" : displacementGate and not displacementOk ? "Need directional displacement" : "Checks passed"
    [allowed, reason, contextOk, sweepOk, smtOk, biasOk, displacementOk]

f_riskPlan(int direction) =>
    float stop = f_tickOut(direction == 1 ? swingLow - stopTicks * syminfo.mintick : swingHigh + stopTicks * syminfo.mintick, direction, false)
    [distance, riskTicks] = f_priceDistance(stop, close, direction)
    float target = f_tickOut(close + direction * distance * rewardR, direction, true)
    [targetDistance, targetTicks] = f_priceDistance(close, target, direction)
    float qty = f_riskQuantity(distance)
    bool valid = bar_index >= swingLookback and not na(stop) and math.floor(riskTicks) >= 1 and math.floor(targetTicks) >= 1 and qty > 0
    [stop, target, distance, qty, valid]

f_jsonNumber(float value) =>
    na(value) ? "null" : str.tostring(value, "0.########")

f_jsonPrice(float value) =>
    na(value) ? "null" : str.tostring(value, format.mintick)

f_jsonEntry(float value) =>
    na(value) ? "null" : str.tostring(value, "0.################")

f_jsonBool(bool value) =>
    value ? "true" : "false"

f_jsonString(string value) =>
    string escaped = str.replace_all(str.replace_all(value, "\\", "\\\\"), "\"", "\\\"")
    escaped := str.replace_all(str.replace_all(escaped, "\n", "\\n"), "\t", "\\t")
    // Pine supports newline/tab escapes, but not a carriage-return literal escape.
    // Match any remaining JSON control characters by their regex code points.
    if str.length(str.match(escaped, "[\\x00-\\x1F]")) > 0
        string hex = "0123456789abcdef"
        for code = 0 to 31
            string suffix = (code < 16 ? "0" : "1") + str.substring(hex, code % 16, code % 16 + 1)
            string control = str.match(escaped, "\\x" + suffix)
            if str.length(control) > 0
                escaped := str.replace_all(escaped, control, "\\u00" + suffix)
    "\"" + escaped + "\""

var bool paperActive = false
var int paperDir = 0
var float paperEntry = na
var float paperStop = na
var float paperTarget = na
var float paperQty = na
var float paperRisk = na
var int paperEntryBar = na
var int paperEntryTime = na
// Detached from the bounded zones array, so later evictions cannot lose exit identity.
var Zone paperSetup = na
var int paperTrades = 0
var int paperWins = 0
var int paperLosses = 0
var int paperAmbiguous = 0
var float paperNetR = 0
var float paperLastR = na
var float paperExitPrice = na
var string paperExitReason = "No resolved sample"
var int dayResolved = 0
var float dayNetR = 0
var bool dayLocked = false
var float nyOpen = na
var int nyOpenTime = na
var float sessionHi = na
var float sessionLo = na
var string deskReason = "Wait for a confirmed three-candle gap"
var int analysisDir = 0
var bool factorContext = false
var bool factorSweep = false
var bool factorSMT = false
var bool factorBias = false
var bool factorDisplacement = false
var bool factorSession = false
var bool smtAvailable = false
var int lastStampA = na
var int lastStampB = na
var int lastStampC = na

bool newGap = false
bool newInversion = false
bool newInvalidation = false
bool newExpiry = false
bool newLong = false
bool newShort = false
bool paperClosed = false
int signalDir = 0
int signalZoneId = na
float signalEntry = na
float signalStop = na
float signalTarget = na
float signalQty = na

f_setupId(Zone z) =>
    syminfo.tickerid + "|" + timeframe.period + "|" + str.tostring(z.bornTime) + "|" + (z.dir == 1 ? "bull" : "bear")

f_auditSettings() =>
    string s = "{\"entry_mode\":" + f_jsonString(entryMode) + ",\"atr_length\":" + str.tostring(atrLen) + ",\"min_gap_atr\":" + f_jsonEntry(minGapAtr) + ",\"birth_body_atr\":" + f_jsonEntry(birthBodyAtr) + ",\"max_zone_age\":" + str.tostring(maxZoneAge) + ",\"zone_cap\":" + str.tostring(zoneCap)
    s += ",\"htf_input\":[" + f_jsonString(tfInputA) + "," + f_jsonString(tfInputB) + "," + f_jsonString(tfInputC) + "],\"htf_resolved\":[" + f_jsonString(tfA) + "," + f_jsonString(tfB) + "," + f_jsonString(tfC) + "],\"lanes_enabled\":[" + f_jsonBool(laneOnA) + "," + f_jsonBool(laneOnB) + "," + f_jsonBool(laneOnC) + "],\"htf_max_age\":" + str.tostring(htfMaxAge)
    s += ",\"gates\":{\"context\":" + f_jsonBool(htfGate) + ",\"sweep\":" + f_jsonBool(sweepGate) + ",\"smt\":" + f_jsonBool(smtGate) + ",\"bias\":" + f_jsonBool(biasGate) + ",\"displacement\":" + f_jsonBool(displacementGate) + ",\"session\":" + f_jsonBool(sessionGate) + "}"
    s += ",\"sweep_lookback\":" + str.tostring(sweepLookback) + ",\"sweep_window\":" + str.tostring(sweepWindow) + ",\"displacement_atr\":" + f_jsonEntry(displacementAtr) + ",\"smt_symbol\":" + f_jsonString(smtSymbol) + ",\"smt_relationship\":" + f_jsonString(smtRelationship) + ",\"smt_lookback\":" + str.tostring(smtLookback) + ",\"entry_session\":" + f_jsonString(tradeSession) + ",\"timezone\":" + f_jsonString(NY_TZ)
    s += ",\"cash_risk\":" + f_jsonEntry(cashRisk) + ",\"reward_r\":" + f_jsonEntry(rewardR) + ",\"swing_lookback\":" + str.tostring(swingLookback) + ",\"stop_ticks\":" + str.tostring(stopTicks) + ",\"quantity_step\":" + f_jsonEntry(qtyStep) + ",\"max_trades_day\":" + str.tostring(maxTradesDay) + ",\"max_hold_bars\":" + str.tostring(maxHoldBars) + ",\"close_at_session_end\":" + f_jsonBool(closeAtSessionEnd)
    s += ",\"audit_enabled\":" + f_jsonBool(auditEnabled) + ",\"audit_cap\":" + str.tostring(auditCap) + ",\"entry_json_alerts\":" + f_jsonBool(sendJsonAlerts) + "}"
    s

// Settings are constant within a calculation. Export once alongside the event lines.
var string auditSettings = f_auditSettings()
var int auditDatasetStart = time

f_auditLane(array<HGap> lane, int direction, bool enabled, int stamp) =>
    string matches = ""
    if array.size(lane) > 0
        for g in lane
            if g.dir == direction and high >= g.lo and low <= g.hi
                matches += (matches == "" ? "" : ",") + "{\"low\":" + f_jsonEntry(g.lo) + ",\"high\":" + f_jsonEntry(g.hi) + ",\"age\":" + str.tostring(g.age) + "}"
    "{\"enabled\":" + f_jsonBool(enabled) + ",\"source_open_ms\":" + f_jsonNumber(stamp) + ",\"aligned_overlaps\":[" + matches + "]}"

f_auditRiskReason(int direction) =>
    [stop, target, distance, qty, valid] = f_riskPlan(direction)
    [stopDistance, riskTicks] = f_priceDistance(stop, close, direction)
    [targetDistance, targetTicks] = f_priceDistance(close, target, direction)
    string why = ""
    why += bar_index < swingLookback or na(stop) ? "Stop history unavailable; " : ""
    why += not na(riskTicks) and math.floor(riskTicks) < 1 ? "Stop distance below one tick or wrong side; " : ""
    why += not na(targetTicks) and math.floor(targetTicks) < 1 ? "Target distance below one tick or wrong side; " : ""
    why += qty <= 0 ? "Quantity estimate is zero; " : ""
    why

f_auditGateReason(int direction) =>
    [allowed, reason, cx, sw, sm, bi, dp] = f_gates(direction)
    string why = ""
    why += sessionGate and not inSession ? "Outside NY entry session; " : ""
    why += htfGate and not cx ? "Need aligned HTF overlap; " : ""
    why += sweepGate and not sw ? "Need recent liquidity sweep; " : ""
    why += smtGate and not sm ? (smtAvailableNow ? "Need SMT divergence; " : "SMT unavailable / stale; ") : ""
    why += biasGate and not bi ? "Prior-day bias disagrees; " : ""
    why += displacementGate and not dp ? "Need directional displacement; " : ""
    why + f_auditRiskReason(direction)

// Called at the decision site, only on confirmed bars. Serialize NOW, never in a renderer.
f_auditRecord(Zone z, string kind, string reason, string detail) =>
    if auditEnabled and barstate.isconfirmed
        int direction = -z.dir
        string setupId = f_setupId(z)
        [allowed, gateReason, cx, sw, sm, bi, dp] = f_gates(direction)
        [plannedStop, plannedTarget, distance, qty, riskOk] = f_riskPlan(direction)
        bool overlaps = low <= z.hi and high >= z.lo
        bool rejects = direction == 1 ? close > z.hi and close > open : close < z.lo and close < open
        string payload = "{\"schema\":\"ifvg-desk.audit.v1\",\"version\":" + f_jsonString(AUDIT_VERSION) + ",\"setup_id\":" + f_jsonString(setupId) + ",\"event_id\":" + f_jsonString(setupId + "|" + str.tostring(time_close) + "|" + kind) + ",\"event\":" + f_jsonString(kind) + ",\"reason\":" + f_jsonString(reason)
        payload += ",\"symbol\":" + f_jsonString(syminfo.tickerid) + ",\"timeframe\":" + f_jsonString(timeframe.period) + ",\"bar_open_ms\":" + str.tostring(time) + ",\"bar_close_ms\":" + str.tostring(time_close) + ",\"confirmed\":true,\"costs_included\":false,\"ohlc\":[" + f_jsonEntry(open) + "," + f_jsonEntry(high) + "," + f_jsonEntry(low) + "," + f_jsonEntry(close) + "]"
        payload += ",\"gap\":{\"zone_id\":" + str.tostring(z.id) + ",\"direction\":" + str.tostring(z.dir) + ",\"trade_direction\":" + str.tostring(direction) + ",\"low\":" + f_jsonEntry(z.lo) + ",\"high\":" + f_jsonEntry(z.hi) + ",\"born_open_ms\":" + str.tostring(z.bornTime) + ",\"flip_close_ms\":" + f_jsonNumber(z.flipTime) + ",\"phase\":" + str.tostring(z.phase) + ",\"age_bars\":" + str.tostring(bar_index - z.bornBar) + "}"
        payload += ",\"trigger\":{\"mode\":" + f_jsonString(entryMode) + ",\"inversion_this_bar\":" + f_jsonBool(not na(z.flipBar) and z.flipBar == bar_index) + ",\"later_than_flip\":" + f_jsonBool(not na(z.flipBar) and bar_index > z.flipBar) + ",\"overlaps\":" + f_jsonBool(overlaps) + ",\"directional_rejection\":" + f_jsonBool(rejects) + "}"
        payload += ",\"factors\":{\"context\":" + f_jsonBool(cx) + ",\"sweep\":" + f_jsonBool(sw) + ",\"smt\":" + f_jsonBool(sm) + ",\"smt_available\":" + f_jsonBool(smtAvailableNow) + ",\"bias\":" + f_jsonBool(bi) + ",\"displacement\":" + f_jsonBool(dp) + ",\"session\":" + f_jsonBool(inSession) + ",\"gates_passed\":" + f_jsonBool(allowed) + "}"
        payload += ",\"evidence\":{\"atr\":" + f_jsonEntry(atrValue) + ",\"middle_body\":" + f_jsonEntry(auditMiddleBody) + ",\"middle_atr\":" + f_jsonEntry(auditMiddleAtr) + ",\"two_bars_ago_high\":" + f_jsonEntry(auditOldHigh) + ",\"two_bars_ago_low\":" + f_jsonEntry(auditOldLow) + ",\"sweep_reference\":" + f_jsonEntry(direction == 1 ? sweepPriorLow : sweepPriorHigh) + ",\"bars_since_sweep\":" + f_jsonNumber(direction == 1 ? barsSinceBullSweep : barsSinceBearSweep) + ",\"prior_day_open\":" + f_jsonEntry(pdOpen) + ",\"prior_day_close\":" + f_jsonEntry(pdClose)
        payload += ",\"smt_source_open_ms\":" + f_jsonNumber(smtStamp) + ",\"own_prior_bar_high\":" + f_jsonEntry(auditPriorBarHigh) + ",\"own_prior_bar_low\":" + f_jsonEntry(auditPriorBarLow) + ",\"own_prior_high\":" + f_jsonEntry(ownPriorHigh) + ",\"own_prior_low\":" + f_jsonEntry(ownPriorLow) + ",\"comparator_high\":" + f_jsonEntry(smtHigh) + ",\"comparator_low\":" + f_jsonEntry(smtLow) + ",\"comparator_prior_high\":" + f_jsonEntry(smtPriorHigh) + ",\"comparator_prior_low\":" + f_jsonEntry(smtPriorLow)
        payload += ",\"htf_lanes\":[" + f_auditLane(laneA, direction, laneOnA, stampA) + "," + f_auditLane(laneB, direction, laneOnB, stampB) + "," + f_auditLane(laneC, direction, laneOnC, stampC) + "]}"
        payload += ",\"candidate_plan\":{\"entry\":" + f_jsonEntry(close) + ",\"stop\":" + f_jsonEntry(plannedStop) + ",\"target\":" + f_jsonEntry(plannedTarget) + ",\"risk_distance\":" + f_jsonEntry(distance) + ",\"quantity\":" + f_jsonEntry(qty) + ",\"valid\":" + f_jsonBool(riskOk) + "},\"detail\":" + detail + "}"
        string summary = "Gap " + f_jsonEntry(z.lo) + "–" + f_jsonEntry(z.hi) + " · close " + f_jsonEntry(close) + " · overlap " + f_jsonBool(overlaps) + " · reject " + f_jsonBool(rejects) + "\n" + reason
        array.push(auditTrail, AuditEvent.new(time_close, setupId, kind, reason, direction, payload, summary))
        if array.size(auditTrail) > auditCap
            array.shift(auditTrail)
            array.set(auditDropped, 0, array.get(auditDropped, 0) + 1)
        if auditLogExport and barstate.isrealtime and (auditSetupFilter == "" or auditSetupFilter == setupId)
            log.info("{0}", payload)
    array.size(auditTrail)

if barstate.isconfirmed
    // Reset before exits: a trade spanning midnight is resolved on its NY exit date.
    if newNyDay
        dayResolved := 0
        dayNetR := 0
        dayLocked := false
        nyOpen := na
        nyOpenTime := na
        // Overnight custom sessions keep one continuous range across NY midnight.
        if not inSession or sessionStart
            sessionHi := na
            sessionLo := na
    if openBar
        nyOpen := open
        nyOpenTime := time
    if inSession
        sessionHi := sessionStart or na(sessionHi) ? high : math.max(sessionHi, high)
        sessionLo := sessionStart or na(sessionLo) ? low : math.min(sessionLo, low)

    if not na(stampA) and (na(lastStampA) or stampA != lastStampA)
        f_updateLane(laneA, closeA, highA, lowA, oldHighA, oldLowA, atrA, bodyA, middleAtrA)
        lastStampA := stampA
    if not na(stampB) and (na(lastStampB) or stampB != lastStampB)
        f_updateLane(laneB, closeB, highB, lowB, oldHighB, oldLowB, atrB, bodyB, middleAtrB)
        lastStampB := stampB
    if not na(stampC) and (na(lastStampC) or stampC != lastStampC)
        f_updateLane(laneC, closeC, highC, lowC, oldHighC, oldLowC, atrC, bodyC, middleAtrC)
        lastStampC := stampC

    // Close-based entries cannot be stopped by price that occurred before entry.
    if paperActive and bar_index > paperEntryBar
        bool stopGap = paperDir == 1 ? open <= paperStop : open >= paperStop
        bool targetGap = paperDir == 1 ? open >= paperTarget : open <= paperTarget
        bool stopTouch = paperDir == 1 ? low <= paperStop : high >= paperStop
        bool targetTouch = paperDir == 1 ? high >= paperTarget : low <= paperTarget
        bool timedOut = bar_index - paperEntryBar >= maxHoldBars
        bool sessionClose = closeAtSessionEnd and not inSession
        float exitPx = na
        string why = ""
        if stopGap
            exitPx := open
            why := "GAP STOP"
        else if targetGap
            exitPx := paperTarget
            why := "TARGET"
        else if stopTouch
            exitPx := paperStop
            why := targetTouch ? "AMBIGUOUS → STOP" : "STOP"
            if targetTouch
                paperAmbiguous += 1
        else if targetTouch
            exitPx := paperTarget
            why := "TARGET"
        else if timedOut or sessionClose
            exitPx := close
            why := sessionClose ? "SESSION CLOSE" : "TIMEOUT"
        if not na(exitPx)
            paperLastR := (exitPx - paperEntry) * paperDir / paperRisk
            paperExitPrice := exitPx
            paperExitReason := why
            paperTrades += 1
            paperWins += paperLastR > 0 ? 1 : 0
            paperLosses += paperLastR < 0 ? 1 : 0
            paperNetR += paperLastR
            dayResolved += 1
            dayNetR += paperLastR
            dayLocked := dayResolved >= maxTradesDay
            paperActive := false
            paperClosed := true
            f_event(why + " · " + str.tostring(paperLastR, "#.##") + "R", paperDir)
            f_auditRecord(paperSetup, "paper_resolved", why, "{\"entry_close_ms\":" + str.tostring(paperEntryTime) + ",\"entry\":" + f_jsonEntry(paperEntry) + ",\"stop\":" + f_jsonEntry(paperStop) + ",\"target\":" + f_jsonEntry(paperTarget) + ",\"quantity\":" + f_jsonEntry(paperQty) + ",\"risk_distance\":" + f_jsonEntry(paperRisk) + ",\"exit\":" + f_jsonEntry(exitPx) + ",\"result_r\":" + f_jsonEntry(paperLastR) + ",\"stop_gap\":" + f_jsonBool(stopGap) + ",\"target_gap\":" + f_jsonBool(targetGap) + ",\"stop_touch\":" + f_jsonBool(stopTouch) + ",\"target_touch\":" + f_jsonBool(targetTouch) + ",\"timed_out\":" + f_jsonBool(timedOut) + ",\"session_close\":" + f_jsonBool(sessionClose) + "}")

    // Existing gaps only: a newly born zone can never invert on its own birth bar.
    if array.size(zones) > 0
        for i = 0 to array.size(zones) - 1
            Zone z = array.get(zones, i)
            if z.phase <= 1
                if bar_index - z.bornBar > maxZoneAge
                    int previousPhase = z.phase
                    z.phase := 4
                    z.endTime := time_close
                    newExpiry := true
                    f_auditRecord(z, "expired", "Zone age exceeded " + str.tostring(maxZoneAge) + " chart bars", "{\"phase_from\":" + str.tostring(previousPhase) + ",\"phase_to\":4}")
                else if z.phase == 0 and bar_index > z.bornBar
                    bool flipped = z.dir == 1 ? close < z.lo : close > z.hi
                    if flipped
                        z.phase := 1
                        z.flipBar := bar_index
                        z.flipTime := time_close
                        newInversion := true
                        f_event(z.dir == -1 ? "Bullish IFVG confirmed" : "Bearish IFVG confirmed", -z.dir)
                        f_auditRecord(z, "inverted", z.dir == 1 ? "Later close strictly below gap low" : "Later close strictly above gap high", "{\"phase_from\":0,\"phase_to\":1}")
                else if z.phase == 1
                    bool invalid = z.dir == -1 ? close < z.lo : close > z.hi
                    if invalid
                        z.phase := 3
                        z.endTime := time_close
                        newInvalidation := true
                        f_auditRecord(z, "invalidated", z.dir == -1 ? "Close strictly below bullish IFVG low" : "Close strictly above bearish IFVG high", "{\"phase_from\":1,\"phase_to\":3}")

    bool middleBodyOk = birthBodyAtr == 0 or math.abs(close[1] - open[1]) >= atrValue[1] * birthBodyAtr
    bool bullGap = low > high[2] and (minGapAtr == 0 or low - high[2] >= atrValue * minGapAtr) and middleBodyOk
    bool bearGap = high < low[2] and (minGapAtr == 0 or low[2] - high >= atrValue * minGapAtr) and middleBodyOk
    if bullGap or bearGap
        int dir = bullGap ? 1 : -1
        array.push(zones, Zone.new(time, dir, 0, bullGap ? low : low[2], bullGap ? high[2] : high, bar_index, time, na, na, na))
        newGap := true
        f_auditRecord(array.last(zones), "gap_detected", bullGap ? "Low strictly above high two bars earlier; birth filters passed" : "High strictly below low two bars earlier; birth filters passed", "{\"phase_to\":0}")
        if array.size(zones) > zoneCap
            f_auditRecord(array.first(zones), "evicted", "Removed from retained zones at capacity " + str.tostring(zoneCap) + "; an existing paper plan continues", "{\"zone_cap\":" + str.tostring(zoneCap) + "}")
            array.shift(zones)

    // Newest qualifying inversion wins; ties use newest birth. No opposing entries.
    int chosen = -1
    int chosenFlip = -1
    int chosenBirth = -1
    int latestCandidateFlip = -1
    string blockedReason = ""
    float chosenStop = na
    float chosenTarget = na
    float chosenRisk = na
    float chosenQty = na
    if not paperActive and not dayLocked and not paperClosed and array.size(zones) > 0
        for i = 0 to array.size(zones) - 1
            Zone z = array.get(zones, i)
            if z.phase == 1
                int direction = -z.dir
                bool overlaps = low <= z.hi and high >= z.lo
                bool rejects = direction == 1 ? close > z.hi and close > open : close < z.lo and close < open
                bool trigger = entryMode == "Inversion close" ? z.flipBar == bar_index : bar_index > z.flipBar and overlaps and rejects
                if trigger
                    [allowed, reason, cx, sw, sm, bi, dp] = f_gates(direction)
                    [plannedStop, plannedTarget, distance, qty, riskOk] = f_riskPlan(direction)
                    if not riskOk
                        reason := "Plan rejected: stop / size / tick risk"
                    if z.flipBar >= latestCandidateFlip
                        blockedReason := reason
                        latestCandidateFlip := z.flipBar
                    if allowed and riskOk and (z.flipBar > chosenFlip or (z.flipBar == chosenFlip and z.bornBar > chosenBirth))
                        chosen := i
                        chosenFlip := z.flipBar
                        chosenBirth := z.bornBar
                        chosenStop := plannedStop
                        chosenTarget := plannedTarget
                        chosenRisk := distance
                        chosenQty := qty
    // Observe every eligible trigger, including locks and losing candidates, before
    // the winner opens its plan. The original selection above remains authoritative.
    if auditEnabled and array.size(zones) > 0
        for i = 0 to array.size(zones) - 1
            Zone z = array.get(zones, i)
            if z.phase == 1
                int direction = -z.dir
                bool overlaps = low <= z.hi and high >= z.lo
                bool rejects = direction == 1 ? close > z.hi and close > open : close < z.lo and close < open
                bool retestTouch = entryMode == "Retest rejection" and bar_index > z.flipBar and overlaps
                bool trigger = entryMode == "Inversion close" ? z.flipBar == bar_index : retestTouch and rejects
                if trigger or retestTouch
                    string winnerId = chosen >= 0 ? f_setupId(array.get(zones, chosen)) : ""
                    string locks = (paperActive ? "Paper plan already active; " : "") + (dayLocked ? "Daily resolved-trade cap reached; " : "") + (paperClosed ? "Exit bar cannot re-enter; " : "")
                    string blockers = locks + f_auditGateReason(direction)
                    bool accepted = i == chosen
                    string why = not trigger ? "Overlap without directional rejection close" : accepted ? "Trigger, required gates and risk passed; selected by newest inversion then birth" : blockers != "" ? blockers : "Another qualifying setup won by newest inversion then birth"
                    string detail = "{\"trigger_passed\":" + f_jsonBool(trigger) + ",\"paper_active\":" + f_jsonBool(paperActive) + ",\"day_locked\":" + f_jsonBool(dayLocked) + ",\"paper_closed_this_bar\":" + f_jsonBool(paperClosed) + ",\"day_resolved\":" + str.tostring(dayResolved) + ",\"winner_setup_id\":" + (winnerId == "" ? "null" : f_jsonString(winnerId)) + "}"
                    f_auditRecord(z, not trigger ? "retest_rejected" : accepted ? "entry_accepted" : "entry_skipped", why, detail)
    if not paperActive and not dayLocked and not paperClosed and array.size(zones) > 0
        if chosen >= 0
            Zone z = array.get(zones, chosen)
            paperDir := -z.dir
            paperEntry := close
            paperStop := chosenStop
            paperRisk := chosenRisk
            paperTarget := chosenTarget
            paperQty := chosenQty
            paperEntryBar := bar_index
            paperEntryTime := time_close
            paperActive := true
            z.phase := 2
            z.endTime := time_close
            paperSetup := z.copy()
            signalDir := paperDir
            signalZoneId := z.id
            signalEntry := paperEntry
            signalStop := paperStop
            signalTarget := paperTarget
            signalQty := paperQty
            newLong := paperDir == 1
            newShort := paperDir == -1
            f_event((paperDir == 1 ? "LONG" : "SHORT") + " · " + str.tostring(paperEntry, format.mintick), paperDir)

    analysisDir := paperActive ? paperDir : 0
    int latestFlip = -1
    if not paperActive and array.size(zones) > 0
        for i = 0 to array.size(zones) - 1
            Zone z = array.get(zones, i)
            if z.phase == 1 and z.flipBar >= latestFlip
                analysisDir := -z.dir
                latestFlip := z.flipBar
        if analysisDir == 0
            int i = array.size(zones) - 1
            while i >= 0 and analysisDir == 0
                Zone z = array.get(zones, i)
                if z.phase == 0
                    analysisDir := -z.dir
                i -= 1
    [analysisAllowed, analysisReason, cx, sw, sm, bi, dp] = f_gates(analysisDir)
    factorContext := cx
    factorSweep := sw
    factorSMT := sm
    factorBias := bi
    factorDisplacement := dp
    factorSession := inSession
    smtAvailable := smtAvailableNow
    deskReason := paperActive ? "Manage fixed stop / target · paper" : dayLocked ? "Daily resolved-trade cap reached" : blockedReason != "" ? blockedReason : latestFlip >= 0 ? (entryMode == "Retest rejection" ? "Wait for a subsequent rejection" : "Wait for a new inversion close") : analysisDir != 0 ? "Wait for a close through the gap" : "Wait for a confirmed three-candle gap"

// Display summaries reflect closed state; strategy and indicator share this source.
int freshCount = 0
int armedCount = 0
int terminalCount = 0
if array.size(zones) > 0
    for i = 0 to array.size(zones) - 1
        Zone z = array.get(zones, i)
        freshCount += z.phase == 0 ? 1 : 0
        armedCount += z.phase == 1 ? 1 : 0
        terminalCount += z.phase >= 2 ? 1 : 0
string deskState = paperActive ? (paperDir == 1 ? "LONG" : "SHORT") : dayLocked ? "DAY DONE" : armedCount > 0 ? "ARMED" : "WAIT"
int factorCount = (factorContext ? 1 : 0) + (factorSweep ? 1 : 0) + (factorSMT ? 1 : 0) + (factorBias ? 1 : 0) + (factorDisplacement ? 1 : 0) + (factorSession ? 1 : 0)
int gateCount = (htfGate ? 1 : 0) + (sweepGate ? 1 : 0) + (smtGate ? 1 : 0) + (biasGate ? 1 : 0) + (displacementGate ? 1 : 0) + (sessionGate ? 1 : 0)
int gatePassed = (htfGate and factorContext ? 1 : 0) + (sweepGate and factorSweep ? 1 : 0) + (smtGate and factorSMT ? 1 : 0) + (biasGate and factorBias ? 1 : 0) + (displacementGate and factorDisplacement ? 1 : 0) + (sessionGate and factorSession ? 1 : 0)
[laneSideA, laneHiA, laneLoA, laneDistA, laneStateA] = f_laneSummary(laneA, laneOnA)
[laneSideB, laneHiB, laneLoB, laneDistB, laneStateB] = f_laneSummary(laneB, laneOnB)
[laneSideC, laneHiC, laneLoC, laneDistC, laneStateC] = f_laneSummary(laneC, laneOnC)

// A bounded report is emitted once after history is rebuilt. Realtime additions
// are emitted by f_auditRecord, never by repeatedly dumping the same snapshots.
if auditEnabled and auditLogExport and barstate.islastconfirmedhistory
    int matching = 0
    if array.size(auditTrail) > 0
        for event in auditTrail
            if auditSetupFilter == "" or auditSetupFilter == event.setupId
                matching += 1
    string report = "{\"schema\":\"ifvg-desk.report.v1\",\"version\":" + f_jsonString(AUDIT_VERSION) + ",\"symbol\":" + f_jsonString(syminfo.tickerid) + ",\"timeframe\":" + f_jsonString(timeframe.period) + ",\"script_kind\":" + f_jsonString(IS_STRATEGY ? "strategy" : "indicator") + ",\"dataset_first_open_ms\":" + str.tostring(auditDatasetStart) + ",\"through_close_ms\":" + str.tostring(time_close) + ",\"feed_session\":" + f_jsonString(syminfo.session) + ",\"feed_timezone\":" + f_jsonString(syminfo.timezone) + ",\"mintick\":" + f_jsonEntry(syminfo.mintick) + ",\"point_value\":" + f_jsonEntry(syminfo.pointvalue) + ",\"currency\":" + f_jsonString(syminfo.currency)
    report += ",\"setup_filter\":" + f_jsonString(auditSetupFilter) + ",\"retained_events\":" + str.tostring(array.size(auditTrail)) + ",\"matching_events\":" + str.tostring(matching) + ",\"dropped_events\":" + str.tostring(array.get(auditDropped, 0)) + ",\"oldest_retained_close_ms\":" + (array.size(auditTrail) > 0 ? str.tostring(array.first(auditTrail).stamp) : "null") + ",\"history_is_bounded\":true,\"recalculation_rebuilds_history\":true,\"broker_properties\":\"Attach Strategy Properties separately; audit is the paper engine\",\"settings\":" + auditSettings + "}"
    log.info("{0}", report)
    if array.size(auditTrail) > 0
        for event in auditTrail
            if auditSetupFilter == "" or auditSetupFilter == event.setupId
                log.info("{0}", event.payload)

// Plain alert conditions plus one complete close-confirmed JSON entry event.
alertcondition(newLong, "IFVG Desk · Long", "Confirmed IFVG long paper plan on {{ticker}} {{interval}} · close {{close}}")
alertcondition(newShort, "IFVG Desk · Short", "Confirmed IFVG short paper plan on {{ticker}} {{interval}} · close {{close}}")
alertcondition(newInversion, "IFVG Desk · Inversion", "A gap inverted at closed bar on {{ticker}} {{interval}}. Gates / risk may still block entry.")
alertcondition(newGap, "IFVG Desk · New gap", "A three-candle FVG formed on the confirmed close of {{ticker}} {{interval}}. This is context, not an entry.")
alertcondition(newInvalidation, "IFVG Desk · IFVG invalidated", "An armed IFVG closed through its opposite edge on {{ticker}} {{interval}} and is no longer eligible.")
alertcondition(newExpiry, "IFVG Desk · Zone expired", "A retained active chart zone exceeded its configured age on {{ticker}} {{interval}} and is no longer eligible.")
alertcondition(paperClosed, "IFVG Desk · Paper resolved", "IFVG Desk paper trade resolved on {{ticker}} {{interval}}. Paper record excludes costs.")
if barstate.isconfirmed and sendJsonAlerts and signalDir != 0
    string eventId = syminfo.tickerid + "|" + timeframe.period + "|" + str.tostring(time_close) + "|" + str.tostring(signalZoneId)
    string payload = "{\"schema\":\"ifvg-desk.v1\",\"event\":\"paper_entry\",\"id\":" + f_jsonString(eventId) + ",\"symbol\":" + f_jsonString(syminfo.tickerid) + ",\"timeframe\":" + f_jsonString(timeframe.period) + ",\"bar_close_ms\":" + str.tostring(time_close) + ",\"zone_id\":" + str.tostring(signalZoneId) + ",\"direction\":" + str.tostring(signalDir) + ",\"entry\":" + f_jsonEntry(signalEntry) + ",\"stop\":" + f_jsonPrice(signalStop) + ",\"target\":" + f_jsonPrice(signalTarget) + ",\"quantity_estimate\":" + f_jsonNumber(signalQty) + ",\"symbol_currency\":" + f_jsonString(syminfo.currency)
    payload += ",\"setup_id\":" + f_jsonString(f_setupId(paperSetup))
    payload += ",\"factors\":{\"context\":" + f_jsonBool(factorContext) + ",\"sweep\":" + f_jsonBool(factorSweep) + ",\"smt\":" + f_jsonBool(factorSMT) + ",\"smt_available\":" + f_jsonBool(smtAvailable) + ",\"bias\":" + f_jsonBool(factorBias) + ",\"displacement\":" + f_jsonBool(factorDisplacement) + ",\"session\":" + f_jsonBool(factorSession) + "}"
    payload += ",\"gates\":{\"context\":" + f_jsonBool(htfGate) + ",\"sweep\":" + f_jsonBool(sweepGate) + ",\"smt\":" + f_jsonBool(smtGate) + ",\"bias\":" + f_jsonBool(biasGate) + ",\"displacement\":" + f_jsonBool(displacementGate) + ",\"session\":" + f_jsonBool(sessionGate) + "},\"confirmed\":true,\"costs_included\":false}"
    alert(payload, alert.freq_once_per_bar_close)

// ============================================================================
// IFVG DESK / DISPLAY ONLY — Dashboard + Chart Studio + Narrative + Audit (v1.2)
// Reads the shared engine. No renderer setting participates in an entry decision.
// v1.1: the panel answers the trader's questions (what · why not · what next),
// lanes show alignment with the side being analysed, checks read required /
// optional, the plan shows distances, the chart labels only what matters.
// v1.2: optional paged history reads frozen engine events by stable Setup ID.
// ============================================================================
string vGroup = "Display · your desk"
string vTheme = input.string("Aurora", "Theme", options = ["Aurora", "Royal Gold", "Colorblind safe"], group = vGroup, display = display.none)
string vView = input.string(IS_STRATEGY ? "Minimal" : "Full", "Chart view", options = ["Full", "Focus", "Minimal"], group = vGroup, tooltip = "Full: gap lifecycle, higher-frame zones, session range and reference levels. Focus: entry-chart zones and plans. Minimal: signals and active plan. Display only.", display = display.none)
bool vShowDash = input.bool(not IS_STRATEGY, "Show dashboard", group = vGroup, display = display.none)
string vDashMode = input.string("Compact", "Dashboard rows", options = ["Compact", "Full"], group = vGroup, tooltip = "Compact: state, reason, ribbon, context lanes, checks, plan, today. Full adds the paper record and the event tape.", display = display.none)
string vDashPos = input.string("Top Right", "Dashboard position", options = ["Top Right", "Top Left", "Bottom Right", "Bottom Left", "Middle Right", "Middle Left"], group = vGroup, display = display.none)
string vSize = input.string("Medium", "Dashboard size", options = ["Small", "Medium", "Large"], group = vGroup, display = display.none)
int vZoneLimit = input.int(10, "Visible entry-chart zones", minval = 2, maxval = 24, group = vGroup, display = display.none)
int vOldZones = input.int(3, "Retired zones visible", minval = 0, maxval = 8, group = vGroup, display = display.none)
float vRadius = input.float(8.0, "Zone visibility radius · ATR", minval = 1.0, maxval = 50.0, step = 0.5, group = vGroup, tooltip = "Distance filter affects drawings only. All retained zones still feed the engine.", display = display.none)
int vPlanHistory = input.int(3, "Earlier plans visible", minval = 0, maxval = 6, group = vGroup, display = display.none)
bool vContext = input.bool(true, "Project the three context gaps", group = vGroup, display = display.none)
bool vReference = input.bool(true, "09:30 open and previous-day high / low", group = vGroup, display = display.none)
bool vSession = input.bool(true, "New York session range", group = vGroup, display = display.none)
bool vMarks = input.bool(true, "Inversion marks and signal tickets", group = vGroup, display = display.none)
bool vTape = input.bool(true, "Recent event tape (Full rows)", group = vGroup, display = display.none)
string vNarrGroup = "Co-Pilot · plain-language read"
string vNarr = input.string(IS_STRATEGY ? "Off" : "Standard", "Narrative depth", options = ["Off", "Brief", "Standard", "Detailed"], group = vNarrGroup, display = display.none)
string vNarrPos = input.string("Bottom Right", "Narrative position", options = ["Top Right", "Top Left", "Bottom Right", "Bottom Left", "Middle Right", "Middle Left"], group = vNarrGroup, tooltip = "If it shares the dashboard corner, the narrative automatically moves to the opposite lower corner.", display = display.none)
string vNarrSize = input.string("Medium", "Narrative size", options = ["Small", "Medium", "Large"], group = vNarrGroup, display = display.none)
bool vWhyOn = input.bool(true, "Show WHY", group = vNarrGroup, display = display.none)
bool vPlanOn = input.bool(true, "Show PLAN", group = vNarrGroup, display = display.none)
bool vLimitsOn = input.bool(true, "Show data limits", group = vNarrGroup, display = display.none)
string vAuditGroup = "Audit · setup history"
bool vAuditShow = input.bool(false, "Show setup history", group = vAuditGroup, tooltip = "Browse the frozen reasons recorded on each decision bar. Independent of the dashboard and narrative. Hover an event for its complete setup ID and saved diagnostic record.", display = display.none)
string vAuditPos = input.string("Bottom Left", "Setup history position", options = ["Top Right", "Top Left", "Bottom Right", "Bottom Left", "Middle Right", "Middle Left"], group = vAuditGroup, tooltip = "Moves to an unused position if the dashboard or narrative already occupies this position.", display = display.none)
int vAuditPage = input.int(1, "Setup history page · newest first", minval = 1, maxval = 63, group = vAuditGroup, tooltip = "Eight events per page. Page 1 shows the newest matching records. Pages beyond the retained history are clamped to the last available page. Use the full Setup ID filter in the audit settings to follow one setup.", display = display.none)

color vBg = vTheme == "Royal Gold" ? #151411 : #101724
color vBand = vTheme == "Royal Gold" ? #25221B : #1A2637
color vStripe = vTheme == "Royal Gold" ? #1C1A16 : #141E2D
color vBorder = vTheme == "Royal Gold" ? #574D34 : #34465F
color vAccent = vTheme == "Royal Gold" ? #DDC68B : #AABFE4
color vUp = vTheme == "Colorblind safe" ? #65B8FF : vTheme == "Royal Gold" ? #83D7BA : #53DAC6
color vDown = vTheme == "Colorblind safe" ? #FFC169 : #FFB454
color vText = #E4EBF5
color vMuted = #A2AFC0
color vDim = #697B91
string vBodySize = vSize == "Large" ? size.normal : vSize == "Medium" ? size.small : size.tiny
string vHeroSize = vSize == "Large" ? size.large : size.normal
string vNarrBody = vNarrSize == "Large" ? size.normal : vNarrSize == "Medium" ? size.small : size.tiny
float vAtrRaw = ta.atr(14)
float vAtr = barstate.isconfirmed ? vAtrRaw : vAtrRaw[1]
float vNarrHigh = ta.highest(high, 100)
float vNarrLow = ta.lowest(low, 100)
float vPrice = barstate.isconfirmed ? close : close[1]
int vClock = barstate.isconfirmed ? time_close : time_close[1]
int vBarMs = int(timeframe.in_seconds() * 1000)

vPos(string p) =>
    switch p
        "Top Left" => position.top_left
        "Bottom Left" => position.bottom_left
        "Bottom Right" => position.bottom_right
        "Middle Left" => position.middle_left
        "Middle Right" => position.middle_right
        => position.top_right

vPx(float p) => na(p) ? "—" : str.tostring(p, format.mintick)
vNum(float p) => na(p) ? "—" : str.tostring(p, "0.0")
vSide(int d) => d == 1 ? "LONG" : d == -1 ? "SHORT" : "—"
vSideLower(int d) => d == 1 ? "long" : d == -1 ? "short" : "—"
vZone(int d) => d == 1 ? "bull" : d == -1 ? "bear" : "—"
vZoneWord(int d) => d == 1 ? "bullish" : d == -1 ? "bearish" : "—"
vColor(int d) => d == 1 ? vUp : d == -1 ? vDown : vMuted
vTf(string tf) => str.contains(tf, "D") or str.contains(tf, "W") or str.contains(tf, "M") or str.contains(tf, "S") ? tf : tf + "m"
vJoin(string acc, string item) => acc == "" ? item : acc + " · " + item
vClip(string txt, int limit) => str.length(txt) > limit ? str.substring(txt, 0, limit - 1) + "…" : txt
vAuditShortId(string id) => str.length(id) > 16 ? "…" + str.substring(id, str.length(id) - 16) : id
// Display transformations use saved snapshot fields, never today's confluence.
vAuditTip(AuditEvent e) =>
    "Setup ID: " + e.setupId + "\n" + str.format_time(e.stamp, "yyyy-MM-dd HH:mm:ss", NY_TZ) + " New York\n" + e.kind + " · " + vSide(e.dir) + " · " + e.reason + "\n" + e.summary + "\n\n" + e.payload
vAuditFreePos(string requested, string narrativePos, bool narrativeOn) =>
    string out = requested
    bool occupied = (vShowDash and requested == vDashPos) or (narrativeOn and requested == narrativePos)
    if occupied
        array<string> choices = array.from("Bottom Left", "Top Left", "Middle Left", "Middle Right", "Bottom Right", "Top Right")
        for i = 0 to array.size(choices) - 1
            string candidate = array.get(choices, i)
            if (not vShowDash or candidate != vDashPos) and (not narrativeOn or candidate != narrativePos)
                out := candidate
                break
    out
// "0930-1100" → "09:30–11:00 NY"; the session input may carry a days suffix.
vSessionText() =>
    string s = tradeSession
    str.length(s) >= 9 ? str.substring(s, 0, 2) + ":" + str.substring(s, 2, 4) + "–" + str.substring(s, 5, 7) + ":" + str.substring(s, 7, 9) + " NY" : s + " NY"
vSymbolShort(string sym) =>
    int at = str.pos(sym, ":")
    na(at) ? sym : str.substring(sym, at + 1)
// Mirrors f_gates' order: the first required check that fails right now.
vGateReason() =>
    sessionGate and not factorSession ? "outside " + vSessionText() : htfGate and not factorContext ? "no aligned HTF gap overlap" : sweepGate and not factorSweep ? "no recent sweep" : smtGate and not factorSMT ? (smtAvailable ? "SMT not confirming" : "SMT has no data") : biasGate and not factorBias ? "prior-day bias disagrees" : displacementGate and not factorDisplacement ? "no displacement on the bar" : ""
// The latest inverted zone still eligible (phase 1): what ARMED refers to.
vArmedZone() =>
    float hi = na
    float lo = na
    int dir = 0
    int flipBar = -1
    if array.size(zones) > 0
        for i = 0 to array.size(zones) - 1
            Zone z = array.get(zones, i)
            if z.phase == 1 and z.flipBar >= flipBar
                hi := z.hi
                lo := z.lo
                dir := -z.dir
                flipBar := z.flipBar
    [hi, lo, dir, flipBar]
vCell(table t, int c, int r, string txt, color fg, color bg, string sz, string align, string tip = "") =>
    table.cell(t, c, r, txt, text_color = fg, bgcolor = bg, text_size = sz, text_halign = align, text_font_family = font.family_monospace, tooltip = tip)
vMerged(table t, int r, string txt, color fg, color bg, string sz, string align = text.align_left, string tip = "") =>
    table.merge_cells(t, 0, r, 3, r)
    vCell(t, 0, r, txt, fg, bg, sz, align, tip)
vBandRow(table t, int r, string txt) =>
    table.merge_cells(t, 0, r, 3, r)
    vCell(t, 0, r, txt, vAccent, vBand, vBodySize, text.align_left)
vWhere(int side, float dist, float hi, float lo) =>
    side == 0 ? "—" : dist == 0 ? "inside" : (lo > vPrice ? "↑ " : "↓ ") + vNum(dist / math.max(vAtr, syminfo.mintick)) + " ATR"
vLane(table t, int r, string tf, int side, float dist, float hi, float lo) =>
    color bg = r % 2 == 0 ? vStripe : vBg
    bool aligned = analysisDir != 0 and side == analysisDir
    string tip = "Nearest retained confirmed " + vTf(tf) + " gap: " + vPx(lo) + " to " + vPx(hi) + ". Aligned = the gap points the way the " + vSideLower(analysisDir) + " would trade. The HTF check searches ALL aligned gaps in enabled lanes, not only this nearest one."
    vCell(t, 0, r, vTf(tf), vText, bg, vBodySize, text.align_right, tip)
    vCell(t, 1, r, side == 0 ? "—" : analysisDir == 0 ? "·" : aligned ? "✓" : "✗", side == 0 ? vDim : analysisDir == 0 ? vDim : aligned ? vUp : vDown, bg, vBodySize, text.align_center, tip)
    vCell(t, 2, r, side == 0 ? "no gap in range" : vZone(side) + " " + vPx(lo) + "–" + vPx(hi), vColor(side), bg, vBodySize, text.align_left, tip)
    vCell(t, 3, r, vWhere(side, dist, hi, lo), vMuted, bg, vBodySize, text.align_right, "Where the gap sits versus the last confirmed close, in chart ATR. Inside = the close is within the gap.")
vCheck(table t, int r, string name, bool pass, bool gate, bool available, string info) =>
    color bg = r % 2 == 0 ? vStripe : vBg
    table.merge_cells(t, 0, r, 1, r)
    vCell(t, 0, r, name, gate ? vText : vMuted, bg, vBodySize, text.align_left, info)
    vCell(t, 2, r, available ? pass ? "✓ met" : "✗ not met" : "— no data", available and pass ? vUp : available ? gate ? vDown : vMuted : vMuted, bg, vBodySize, text.align_left, info)
    vCell(t, 3, r, gate ? "● required" : "○ optional", gate ? vAccent : vDim, bg, vBodySize, text.align_right, "Required checks must be met on the entry bar. Optional checks are shown for context and never block an entry. Switch any check in 03 · Confluence gates.")
vPlanRow(table t, int r, string name, string value, color fg, string tip = "") =>
    color bg = r % 2 == 0 ? vStripe : vBg
    table.merge_cells(t, 1, r, 3, r)
    vCell(t, 0, r, name, vMuted, bg, vBodySize, text.align_right, tip)
    vCell(t, 1, r, value, fg, bg, vBodySize, text.align_left, tip)
vMark(bool pass, bool available) => available ? pass ? "✓" : "✗" : "—"

// Bounded pools are rebuilt only on the last bar from engine-owned state.
var array<box> vBoxes = array.new<box>()
var array<line> vLines = array.new<line>()
var array<label> vLabels = array.new<label>()
var array<float> vUsedY = array.new<float>()
vLine(int x1, float y, int x2, color col, string style, int width = 1) =>
    array.push(vLines, line.new(x1, y, x2, y, xloc = xloc.bar_time, color = col, style = style, width = width))
vBox(int x1, float hi, int x2, float lo, color col, int opacity, string txt = "", string borderStyle = line.style_solid) =>
    array.push(vBoxes, box.new(x1, hi, x2, lo, xloc = xloc.bar_time, border_color = color.new(col, math.min(85, opacity - 15)), bgcolor = color.new(col, opacity), border_style = borderStyle, text = txt, text_color = color.new(col, 8), text_size = size.tiny, text_halign = text.align_left, text_valign = text.align_top))
// leftward = the text extends to the left of x (used for flip marks, so they never sit on a zone's own label).
vTag(int x, float y, string txt, color col, string tip, bool nudge = false, bool leftward = false) =>
    float labelY = y
    if nudge
        float gap = math.max(vAtr * 0.6, syminfo.mintick * 8)
        if array.size(vUsedY) > 0
            // Moving upward is safe in one pass only when lower levels come first.
            array.sort(vUsedY, order.ascending)
            for j = 0 to array.size(vUsedY) - 1
                float occupied = array.get(vUsedY, j)
                if math.abs(labelY - occupied) < gap
                    labelY := occupied + gap
        array.push(vUsedY, labelY)
        if labelY != y
            array.push(vLines, line.new(x, y, x, labelY, xloc = xloc.bar_time, color = color.new(col, 65), style = line.style_dotted))
    array.push(vLabels, label.new(x, labelY, txt, xloc = xloc.bar_time, color = color.new(vBg, 5), style = leftward ? label.style_label_right : label.style_label_left, textcolor = col, size = size.tiny, tooltip = tip))

type VTicket
    int stamp
    int stopTime
    int dir
    float entry
    float stop
    float target
    float exitPrice
    string result
var array<VTicket> vTickets = array.new<VTicket>()
if barstate.isconfirmed
    if paperClosed and array.size(vTickets) > 0
        VTicket last = array.last(vTickets)
        last.stopTime := time_close
        last.exitPrice := paperExitPrice
        last.result := paperExitReason + " " + vNum(paperLastR) + "R"
    if newLong or newShort
        array.push(vTickets, VTicket.new(time_close, na, signalDir, signalEntry, signalStop, signalTarget, na, ""))
        if array.size(vTickets) > 7
            array.shift(vTickets)
// The most recent completed plan, as one short phrase for the panel and the Co-Pilot.
vLastTicket() =>
    string out = ""
    if array.size(vTickets) > 0
        for offset = 0 to array.size(vTickets) - 1
            VTicket t = array.get(vTickets, array.size(vTickets) - 1 - offset)
            if out == "" and not na(t.stopTime)
                // Earlier days carry their date so "last" is never mistaken for today.
                bool sameDay = dayofmonth(t.stopTime, NY_TZ) == dayofmonth(vClock, NY_TZ) and month(t.stopTime, NY_TZ) == month(vClock, NY_TZ)
                out := str.format_time(t.stopTime, sameDay ? "HH:mm" : "MMM d HH:mm", NY_TZ) + " " + vSideLower(t.dir) + " · " + t.result
    out

// A session silhouette keeps useful context on screen between entries.
var int vSessLeft = na
var int vSessRight = na
if barstate.isconfirmed
    if sessionStart
        vSessLeft := time
    if inSession
        vSessRight := time_close

bool vFull = vView == "Full"
bool vMinimal = vView == "Minimal"
int vVisibleZones = 0
if barstate.islast or barstate.islastconfirmedhistory
    while array.size(vBoxes) > 0
        box.delete(array.pop(vBoxes))
    while array.size(vLines) > 0
        line.delete(array.pop(vLines))
    while array.size(vLabels) > 0
        label.delete(array.pop(vLabels))
    array.clear(vUsedY)
    int rightTime = time_close + 12 * vBarMs
    int tagTime = time_close + 2 * vBarMs
    int contextLeft = time - 30 * vBarMs
    [armedHi, armedLo, armedDir, armedFlip] = vArmedZone()
    if vFull and vSession and not na(vSessLeft) and not na(sessionHi) and not na(sessionLo)
        vBox(vSessLeft, sessionHi, nz(vSessRight, time_close), sessionLo, vAccent, 95, "NEW YORK / SESSION RANGE", line.style_dotted)
    if vFull and vContext
        // Context boxes carry no in-box text (titles overprinted when lanes shared an edge); the lane tag says it.
        // Lanes whose upper edges sit within one label gap and share a side get ONE merged tag ("15m · 60m bear ✓").
        array<string> lnTf = array.new<string>()
        array<int> lnSide = array.new<int>()
        array<float> lnHi = array.new<float>()
        if laneSideA != 0 and laneDistA <= vRadius * vAtr
            vBox(contextLeft, laneHiA, rightTime, laneLoA, vColor(laneSideA), 95, "", line.style_dotted)
            array.push(lnTf, vTf(tfA))
            array.push(lnSide, laneSideA)
            array.push(lnHi, laneHiA)
        if laneSideB != 0 and laneDistB <= vRadius * vAtr
            vBox(contextLeft, laneHiB, rightTime, laneLoB, vColor(laneSideB), 94, "", line.style_dotted)
            array.push(lnTf, vTf(tfB))
            array.push(lnSide, laneSideB)
            array.push(lnHi, laneHiB)
        if laneSideC != 0 and laneDistC <= vRadius * vAtr
            vBox(contextLeft, laneHiC, rightTime, laneLoC, vColor(laneSideC), 93, "", line.style_dotted)
            array.push(lnTf, vTf(tfC))
            array.push(lnSide, laneSideC)
            array.push(lnHi, laneHiC)
        int laneCount = array.size(lnTf)
        if laneCount > 0
            float tagGap = math.max(vAtr * 0.6, syminfo.mintick * 8)
            array<bool> laneUsed = array.new<bool>(laneCount, false)
            for k = 0 to laneCount - 1
                int pick = -1
                for i = 0 to laneCount - 1
                    if not array.get(laneUsed, i) and (pick < 0 or array.get(lnHi, i) < array.get(lnHi, pick))
                        pick := i
                if pick >= 0
                    array.set(laneUsed, pick, true)
                    string names = array.get(lnTf, pick)
                    int side = array.get(lnSide, pick)
                    float y = array.get(lnHi, pick)
                    for i = 0 to laneCount - 1
                        if not array.get(laneUsed, i) and array.get(lnSide, i) == side and math.abs(array.get(lnHi, i) - y) <= tagGap
                            array.set(laneUsed, i, true)
                            names += " · " + array.get(lnTf, i)
                    vTag(rightTime, y, names + " " + vZone(side) + (analysisDir == 0 ? "" : side == analysisDir ? " ✓" : " ✗"), vColor(side), "Nearest confirmed higher-frame gap(s) on these lanes, projected for current context. ✓ = aligned with the side being analysed.", true)
    int retiredShown = 0
    if not vMinimal and array.size(zones) > 0
        for offset = 0 to array.size(zones) - 1
            int idx = array.size(zones) - 1 - offset
            Zone z = array.get(zones, idx)
            bool retired = z.phase >= 2
            float dist = math.max(0, math.max(z.lo - vPrice, vPrice - z.hi))
            bool eligible = vVisibleZones < vZoneLimit and dist <= vRadius * vAtr and (not retired or retiredShown < vOldZones)
            if eligible
                int until = retired ? nz(z.endTime, time_close) : rightTime
                bool flipped = not na(z.flipTime)
                bool armed = z.phase == 1
                color col = vColor(flipped ? -z.dir : z.dir)
                int alpha = retired ? 96 : flipped ? 82 : 94
                if flipped
                    vBox(z.bornTime, z.hi, z.flipTime, z.lo, vColor(z.dir), 96, "", line.style_dotted)
                // Only the latest eligible inversion carries text and a flip mark; every other zone reads by colour and border alone.
                bool latestArmed = armed and z.flipBar == armedFlip
                string zoneText = latestArmed ? "IFVG " + (-z.dir == 1 ? "↑" : "↓") + " · armed" : ""
                vBox(flipped ? z.flipTime : z.bornTime, z.hi, until, z.lo, col, alpha, zoneText, retired ? line.style_dotted : line.style_solid)
                vLine(flipped ? z.flipTime : z.bornTime, (z.hi + z.lo) * 0.5, until, color.new(col, retired ? 85 : 50), line.style_dotted)
                if vMarks and latestArmed
                    // The mark hangs to the left of the inversion close, over the pre-flip gap, clear of the zone's label.
                    vTag(z.flipTime, (z.hi + z.lo) * 0.5, -z.dir == 1 ? "↗ flip" : "↘ flip", col, "Confirmed inversion at " + str.format_time(z.flipTime, "MMM d HH:mm", NY_TZ) + " NY. Original gap " + vPx(z.lo) + "–" + vPx(z.hi) + ". The marker sits on the inversion close, never backdated.", false, true)
                vVisibleZones += 1
                if retired
                    retiredShown += 1
    if vFull and vReference
        if not na(nyOpen) and not na(nyOpenTime)
            vLine(nyOpenTime, nyOpen, rightTime, color.new(vAccent, 25), line.style_dashed)
            vTag(rightTime, nyOpen, "09:30  " + vPx(nyOpen), vAccent, "Open of the exact 09:30 New York bar. Absent when the chart has no bar opening at that time. Reference level, not a promised destination.", true)
        if not na(pdh) and math.abs(pdh - vPrice) <= vRadius * 2 * vAtr
            vLine(contextLeft, pdh, rightTime, color.new(vDown, 45), line.style_dotted)
            vTag(rightTime, pdh, "PDH  " + vPx(pdh), vDown, "Previous completed daily candle high (exchange/session daily bars).", true)
        if not na(pdl) and math.abs(pdl - vPrice) <= vRadius * 2 * vAtr
            vLine(contextLeft, pdl, rightTime, color.new(vUp, 45), line.style_dotted)
            vTag(rightTime, pdl, "PDL  " + vPx(pdl), vUp, "Previous completed daily candle low (exchange/session daily bars).", true)
    if array.size(vTickets) > 0
        int drawnHistory = 0
        for offset = 0 to array.size(vTickets) - 1
            int j = array.size(vTickets) - 1 - offset
            VTicket ticket = array.get(vTickets, j)
            bool active = false
            if na(ticket.stopTime)
                active := true
            if active or not vMinimal and drawnHistory < vPlanHistory
                int rt = active ? rightTime : ticket.stopTime
                color col = vColor(ticket.dir)
                int fade = active ? 0 : 58
                vBox(ticket.stamp, math.max(ticket.entry, ticket.target), rt, math.min(ticket.entry, ticket.target), col, active ? 90 : 97)
                vBox(ticket.stamp, math.max(ticket.entry, ticket.stop), rt, math.min(ticket.entry, ticket.stop), vMuted, active ? 95 : 98)
                vLine(ticket.stamp, ticket.entry, rt, color.new(col, fade), line.style_solid, active ? 2 : 1)
                vLine(ticket.stamp, ticket.stop, rt, color.new(vMuted, fade), line.style_dashed)
                vLine(ticket.stamp, ticket.target, rt, color.new(col, fade), line.style_dashed)
                if vMarks
                    vTag(ticket.stamp, ticket.entry, ticket.dir == 1 ? "▲ LONG" : "▼ SHORT", color.new(col, fade), "Paper entry " + vPx(ticket.entry) + " / stop " + vPx(ticket.stop) + " / target " + vPx(ticket.target) + ". Close-based reference, no costs.")
                if active
                    // Live plan tags sit two bars past the close so they stay inside a normal right margin.
                    vTag(tagTime, ticket.target, "TARGET  " + vPx(ticket.target), col, "Fixed-R paper reference. The strategy companion has separate broker fills and costs.", true)
                    vTag(tagTime, ticket.entry, "ENTRY   " + vPx(ticket.entry), col, "Accepted signal close.", true)
                    vTag(tagTime, ticket.stop, "STOP    " + vPx(ticket.stop), vMuted, "Swing invalidation with tick buffer.", true)
                else if not na(ticket.exitPrice)
                    vTag(rt, ticket.exitPrice, ticket.result, color.new(col, 20), "Resolved paper outcome. Same-bar stop/target collisions are counted stop-first; costs excluded.")
                if not active
                    drawnHistory += 1

// Global plot family: closed-bar pulses never appear intrabar.
plotshape(vMarks and newLong and barstate.isconfirmed, "Long IFVG close", shape.triangleup, location.belowbar, vUp, size = size.small)
plotshape(vMarks and newShort and barstate.isconfirmed, "Short IFVG close", shape.triangledown, location.abovebar, vDown, size = size.small)
bgcolor(vFull and vSession and inSession ? color.new(vAccent, 98) : na, title = "New York session tint")
plot(signalDir, "Accepted signal direction", display = display.data_window)
plot(paperNetR, "Paper cumulative R (no costs)", display = display.data_window)
plot(paperTrades, "Resolved paper trades", display = display.data_window)

var table vDash = table.new(vPos(vDashPos), 4, 40, bgcolor = vShowDash ? vBg : color.new(vBg, 100), frame_color = vShowDash ? vBorder : color.new(vBorder, 100), frame_width = vShowDash ? 1 : 0, border_width = 0)
string vEffectiveNarrPos = vShowDash and vNarrPos == vDashPos ? vDashPos == "Bottom Left" ? "Bottom Right" : "Bottom Left" : vNarrPos
bool vNarrTableOn = not IS_STRATEGY and vNarr != "Off"
var table vNarrTable = table.new(vPos(vEffectiveNarrPos), 2, 9, bgcolor = vNarrTableOn ? vBg : color.new(vBg, 100), frame_color = vNarrTableOn ? vBorder : color.new(vBorder, 100), frame_width = vNarrTableOn ? 1 : 0, border_width = 0)
string vEffectiveAuditPos = vAuditFreePos(vAuditPos, vEffectiveNarrPos, vNarrTableOn)
var table vAuditTable = vAuditShow ? table.new(vPos(vEffectiveAuditPos), 4, 13, bgcolor = vBg, frame_color = vBorder, frame_width = 1, border_width = 0) : na
var label vStrategyNarr = na

if barstate.islast or barstate.islastconfirmedhistory
    table.clear(vDash, 0, 0, 3, 39)
    table.clear(vNarrTable, 0, 0, 1, 8)
    if vAuditShow
        table.clear(vAuditTable, 0, 0, 3, 12)
        // Build page indexes newest first; never reorder or mutate the engine journal.
        array<int> matches = array.new<int>()
        int retained = array.size(auditTrail)
        if retained > 0
            for offset = 0 to retained - 1
                int idx = retained - 1 - offset
                AuditEvent e = array.get(auditTrail, idx)
                if auditSetupFilter == "" or e.setupId == auditSetupFilter
                    array.push(matches, idx)
        int matched = array.size(matches)
        int pages = math.max(1, int(math.ceil(matched / 8.0)))
        int page = math.min(vAuditPage, pages)
        int first = (page - 1) * 8
        int shown = math.min(8, matched - first)
        int dropped = array.get(auditDropped, 0)
        string filterText = auditSetupFilter == "" ? "All setups" : "Setup " + vAuditShortId(auditSetupFilter)
        string limitsTip = "History covers the loaded chart bars and this calculation only. At most " + str.tostring(auditCap) + " records are retained across all setups; older events may be missing from a filtered setup. Input or chart changes rebuild the record. Export diagnostic records from Pine Logs when enabled."
        vMerged(vAuditTable, 0, "SETUP HISTORY · New York time", vAccent, vBand, size.small, text.align_left, "Each row is a saved event-time snapshot. Hover any event to see its full ID, reason, context and diagnostic JSON.")
        vMerged(vAuditTable, 1, filterText, vMuted, vBg, size.tiny, text.align_left, auditSetupFilter == "" ? "All retained setup IDs. Enter an exact full Setup ID in audit settings to filter." : "Exact Setup ID filter: " + auditSetupFilter)
        vMerged(vAuditTable, 2, "Page " + str.tostring(page) + "/" + str.tostring(pages) + " · " + str.tostring(shown) + " of " + str.tostring(matched) + " matches · newest first", vMuted, vBg, size.tiny, text.align_left, "Change Setup history page to browse older records. The page is clamped to available matching events.")
        vCell(vAuditTable, 0, 3, "TIME", vDim, vStripe, size.tiny, text.align_right)
        vCell(vAuditTable, 1, 3, "EVENT / SIDE", vDim, vStripe, size.tiny, text.align_left)
        vCell(vAuditTable, 2, 3, "SAVED REASON", vDim, vStripe, size.tiny, text.align_left)
        vCell(vAuditTable, 3, 3, "SETUP ID", vDim, vStripe, size.tiny, text.align_left)
        if shown > 0
            for j = 0 to shown - 1
                AuditEvent e = array.get(auditTrail, array.get(matches, first + j))
                int row = 4 + j
                color bg = j % 2 == 0 ? vBg : vStripe
                string tip = vAuditTip(e)
                vCell(vAuditTable, 0, row, str.format_time(e.stamp, "MM-dd\nHH:mm", NY_TZ), vMuted, bg, size.tiny, text.align_right, tip)
                vCell(vAuditTable, 1, row, vClip(e.kind, 22) + "\n" + vSide(e.dir), vColor(e.dir), bg, size.tiny, text.align_left, tip)
                vCell(vAuditTable, 2, row, vClip(e.reason, 38), vText, bg, size.tiny, text.align_left, tip)
                vCell(vAuditTable, 3, row, vAuditShortId(e.setupId), vMuted, bg, size.tiny, text.align_left, tip)
        else
            vCell(vAuditTable, 1, 4, auditEnabled ? "No records" : "Recording off", vMuted, vBg, size.tiny, text.align_left)
            vCell(vAuditTable, 2, 4, auditSetupFilter == "" ? "No retained events" : "No exact Setup ID match", vMuted, vBg, size.tiny, text.align_left, limitsTip)
        string retentionText = "Retained " + str.tostring(retained) + "/" + str.tostring(auditCap) + (dropped > 0 ? " · " + str.tostring(dropped) + " older records removed" : " · loaded bars only")
        vMerged(vAuditTable, 12, retentionText, dropped > 0 ? vDown : vDim, vBg, size.tiny, text.align_left, limitsTip)
    [aHi, aLo, aDir, aFlip] = vArmedZone()
    string sess = vSessionText()
    string gateWhy = vGateReason()
    string lastTxt = vLastTicket()
    float actualPlanR = paperActive and paperRisk > 0 ? math.abs(paperTarget - paperEntry) / paperRisk : na
    bool allGatesMet = gatePassed == gateCount
    color heroCol = paperActive ? vColor(paperDir) : deskState == "ARMED" ? vColor(aDir != 0 ? aDir : analysisDir) : deskState == "DAY DONE" ? vAccent : vMuted
    string heroTxt = paperActive ? "● " + vSide(paperDir) + " · PLAN ACTIVE" : deskState == "ARMED" ? "● ARMED · " + vSide(aDir != 0 ? aDir : analysisDir) : deskState
    string reasonTxt = paperActive ? "entry " + vPx(paperEntry) + " · stop " + vPx(paperStop) + " · target " + vPx(paperTarget) + " · " + vNum(actualPlanR) + "R" : dayLocked ? "today's " + (maxTradesDay == 1 ? "plan is used" : str.tostring(maxTradesDay) + " plans are used") + " · entries resume on the next New York day" : aDir != 0 ? vZoneWord(aDir) + " IFVG " + vPx(aLo) + "–" + vPx(aHi) + " · " + (entryMode == "Inversion close" ? "its flip close was not entered · only a new inversion close can enter" : gateWhy != "" ? "waiting: " + gateWhy : "waiting for a retest") : freshCount > 0 ? str.tostring(freshCount) + (freshCount == 1 ? " fresh gap" : " fresh gaps") + " · waiting for a close through one" : "no gap yet · waiting for a three-candle gap"
    string sideTag = (paperActive ? paperDir : aDir != 0 ? aDir : analysisDir) == 1 ? " · for a long" : (paperActive ? paperDir : aDir != 0 ? aDir : analysisDir) == -1 ? " · for a short" : ""
    string smtName = "SMT vs " + vSymbolShort(smtSymbol)
    if vShowDash
        int r = 0
        table.merge_cells(vDash, 0, r, 2, r)
        vCell(vDash, 0, r, "I F V G  D E S K", vAccent, vBand, vBodySize, text.align_left)
        vCell(vDash, 3, r, "v" + AUDIT_VERSION, vMuted, vBand, vBodySize, text.align_right)
        r += 1
        vMerged(vDash, r, heroTxt, heroCol, vBg, vHeroSize, text.align_center, "WAIT: no eligible inverted gap. ARMED: an inverted gap exists — not an entry. PLAN ACTIVE: a paper plan is open. DAY DONE: today's cap of resolved plans is reached.")
        r += 1
        vMerged(vDash, r, reasonTxt, vMuted, vBg, vBodySize, text.align_center, "Why the desk is in this state right now, from the same rules the checks table shows.")
        r += 1
        // Ribbon: done · in progress (amber) · not yet.
        bool stepGap = freshCount > 0 or armedCount > 0 or paperActive
        bool stepFlip = armedCount > 0 or paperActive
        bool checksDone = paperActive or (armedCount > 0 and allGatesMet)
        bool checksPending = not paperActive and armedCount > 0 and not allGatesMet
        string checksTxt = paperActive ? "03 CHECKS ✓" : armedCount > 0 ? (gateCount == 0 ? "03 CHECKS —" : "03 CHECKS " + str.tostring(gatePassed) + "/" + str.tostring(gateCount)) : "03 CHECKS"
        vCell(vDash, 0, r, "01 GAP" + (stepGap ? " ✓" : ""), stepGap ? vBg : vMuted, stepGap ? color.new(heroCol, 20) : vBand, vBodySize, text.align_center, "A three-candle gap exists on the chart.")
        vCell(vDash, 1, r, "02 FLIP" + (stepFlip ? " ✓" : ""), stepFlip ? vBg : vMuted, stepFlip ? color.new(heroCol, 20) : vBand, vBodySize, text.align_center, "A later close inverted a gap.")
        vCell(vDash, 2, r, checksTxt, checksDone ? vBg : checksPending ? vBg : vMuted, checksDone ? color.new(heroCol, 20) : checksPending ? color.new(vAccent, 45) : vBand, vBodySize, text.align_center, "Required checks met over required checks enabled, evaluated on the current closed bar.")
        vCell(vDash, 3, r, "04 PLAN" + (paperActive ? " ▶" : ""), paperActive ? vBg : vMuted, paperActive ? color.new(heroCol, 20) : vBand, vBodySize, text.align_center, "A paper plan with entry, stop and target is open.")
        r += 1
        vBandRow(vDash, r, "CONTEXT" + sideTag + " · nearest gap per frame")
        r += 1
        vCell(vDash, 0, r, "FRAME", vDim, vBg, vBodySize, text.align_right)
        vCell(vDash, 1, r, "ALIGNED", vDim, vBg, vBodySize, text.align_center)
        vCell(vDash, 2, r, "GAP", vDim, vBg, vBodySize, text.align_left)
        vCell(vDash, 3, r, "WHERE", vDim, vBg, vBodySize, text.align_right)
        r += 1
        vLane(vDash, r, tfA, laneSideA, laneDistA, laneHiA, laneLoA)
        r += 1
        vLane(vDash, r, tfB, laneSideB, laneDistB, laneHiB, laneLoB)
        r += 1
        vLane(vDash, r, tfC, laneSideC, laneDistC, laneHiC, laneLoC)
        r += 1
        vBandRow(vDash, r, "CHECKS" + sideTag + " · " + (gateCount == 0 ? "none required" : str.tostring(gatePassed) + " of " + str.tostring(gateCount) + " required met"))
        r += 1
        array<string> cNames = array.from("Session " + sess, "HTF gap overlap", "Sweep", smtName, "Day bias", "Displacement")
        array<bool> cPass = array.from(factorSession, factorContext, factorSweep, factorSMT, factorBias, factorDisplacement)
        array<bool> cGate = array.from(sessionGate, htfGate, sweepGate, smtGate, biasGate, displacementGate)
        array<bool> cAvail = array.from(true, true, true, smtAvailable, true, true)
        array<string> cTips = array.from("The configured New York entry session. Clock follows America/New_York daylight saving rules.", "A still-valid gap on an enabled higher frame, pointing the same way, overlapping the current closed candle. Updates every bar; not a frozen entry-time explanation.", "A recent sweep of a prior rolling high or low: a mechanical proxy for liquidity.", "Synchronised prior-bar comparison against the chosen symbol. Missing data never passes a required SMT check.", "Direction of the previous completed daily candle: a deliberately simple bias.", "Directional displacement on the analysis bar. A rule match, not a probability.")
        for pass = 0 to 1
            for k = 0 to 5
                bool gate = array.get(cGate, k)
                if (pass == 0 and gate) or (pass == 1 and not gate)
                    vCheck(vDash, r, array.get(cNames, k), array.get(cPass, k), gate, array.get(cAvail, k), array.get(cTips, k))
                    r += 1
        vBandRow(vDash, r, paperActive ? "PLAN · ACTIVE" : "PLAN · what the rule does next")
        r += 1
        if paperActive
            vPlanRow(vDash, r, "entry", vPx(paperEntry) + " · " + vSideLower(paperDir) + " · " + str.format_time(nz(paperEntryTime, vClock), "HH:mm", NY_TZ) + " NY", vColor(paperDir), "Accepted signal close.")
            r += 1
            vPlanRow(vDash, r, "stop", vPx(paperStop) + " · " + vPx(paperRisk) + " pts · 1R", vText, "Swing invalidation with tick buffer. Fixed from the signal close.")
            r += 1
            vPlanRow(vDash, r, "target", vPx(paperTarget) + " · " + vPx(math.abs(paperTarget - paperEntry)) + " pts · " + str.tostring(actualPlanR, "0.##") + "R", vText, "Fixed-R reference; outward tick rounding can make it slightly more than the input.")
            r += 1
            vPlanRow(vDash, r, "size", (na(paperQty) ? "—" : str.tostring(paperQty, "0.########")) + " · risk " + str.tostring(cashRisk, "0.##") + " " + syminfo.currency, vMuted, "Quantity = cash risk ÷ (stop distance × point value), floored to the quantity step. Symbol currency; no fees, no FX conversion.")
            r += 1
        else
            vPlanRow(vDash, r, "entry", (entryMode == "Inversion close" ? "the close that inverts a gap" : "a retest of the IFVG + rejecting close") + (sessionGate ? " · inside " + sess : ""), vText, "Entry trigger per the selected mode. Failed checks never create a late entry.")
            r += 1
            vPlanRow(vDash, r, "stop", "beyond the prior " + str.tostring(swingLookback) + "-bar swing + " + str.tostring(stopTicks) + (stopTicks == 1 ? " tick" : " ticks"), vText, "Wrong-side or zero-distance stops reject the plan.")
            r += 1
            vPlanRow(vDash, r, "target", str.tostring(rewardR, "0.##") + "R", vText, "Fixed multiple of the initial risk.")
            r += 1
            vPlanRow(vDash, r, "size", str.tostring(cashRisk, "0.##") + " " + syminfo.currency + " risk ÷ stop distance", vMuted, "Quantity = cash risk ÷ (stop distance × point value), floored to the quantity step.")
            r += 1
        vMerged(vDash, r, "Today " + str.tostring(dayResolved) + " of " + str.tostring(maxTradesDay) + (maxTradesDay == 1 ? " plan" : " plans") + " used" + (lastTxt != "" ? " · last " + lastTxt : ""), vMuted, vBg, vBodySize, text.align_left, "Resolved paper plans on this New York date against the daily cap; the last completed plan and its outcome.")
        r += 1
        if vDashMode == "Full"
            vBandRow(vDash, r, "RECORD · loaded history · no costs")
            r += 1
            vMerged(vDash, r, str.tostring(paperTrades) + (paperTrades == 1 ? " plan" : " plans") + " · " + str.tostring(paperWins) + " closed positive · " + vNum(paperNetR) + "R total", vMuted, vBg, vBodySize, text.align_left, "Deterministic paper record on the loaded chart, before costs. Same-bar stop/target collisions count as stops (" + str.tostring(paperAmbiguous) + " so far). A description of the past, not an edge.")
            r += 1
            string auditRefText = auditEnabled ? "Setup history · " + str.tostring(array.size(auditTrail)) + " records" : "Setup history · recording off"
            string auditRefTip = "Enable Show setup history to browse saved decision reasons. Hover a history row for its full Setup ID; use the exact ID filter to follow one setup."
            if array.size(auditTrail) > 0
                AuditEvent latestAudit = array.last(auditTrail)
                auditRefText += " · latest " + vAuditShortId(latestAudit.setupId)
                auditRefTip += "\n\n" + vAuditTip(latestAudit)
            vMerged(vDash, r, auditRefText, vMuted, vBg, size.tiny, text.align_left, auditRefTip)
            r += 1
            if vTape
                vBandRow(vDash, r, "EVENT TAPE · New York time")
                r += 1
                if array.size(eventTape) > 0
                    for j = 0 to array.size(eventTape) - 1
                        Event e = array.get(eventTape, j)
                        string m = e.message
                        string g = str.contains(m, "confirmed") ? "⚡ " : str.startswith(m, "LONG") ? "▲ " : str.startswith(m, "SHORT") ? "▼ " : str.contains(m, "TARGET") ? "✓ " : str.contains(m, "STOP") ? "✕ " : "• "
                        table.merge_cells(vDash, 1, r, 3, r)
                        color bg = j % 2 == 0 ? vBg : vStripe
                        vCell(vDash, 0, r, str.format_time(e.stamp, "HH:mm", NY_TZ), vMuted, bg, vBodySize, text.align_right)
                        vCell(vDash, 1, r, g + m, vColor(e.dir), bg, vBodySize, text.align_left)
                        r += 1
        vMerged(vDash, r, "Rules, not odds · ProEA Lab · open source · MIT", vDim, vBg, size.tiny, text.align_center)
    if vNarr != "Off"
        string nowText = paperActive ? (paperDir == 1 ? "Long" : "Short") + " plan active from " + vPx(paperEntry) + ".\nStop " + vPx(paperStop) + " (" + vPx(paperRisk) + " pts) · target " + vPx(paperTarget) + " (" + str.tostring(actualPlanR, "0.##") + "R planned)." : dayLocked ? "Today's " + (maxTradesDay == 1 ? "plan is used" : "plans are used") + (lastTxt != "" ? " (last: " + lastTxt + ")" : "") + ".\nEntries resume on the next New York day." : aDir != 0 ? "A " + vZoneWord(aDir) + " IFVG is armed at " + vPx(aLo) + "–" + vPx(aHi) + ".\n" + (entryMode == "Inversion close" ? "Its flip close was not entered. Only a new inversion close can enter." : gateWhy != "" ? "Not entered: " + gateWhy + "." : "Waiting for a retest with a rejecting close.") : freshCount > 0 ? "No inverted gap yet. " + str.tostring(freshCount) + (freshCount == 1 ? " fresh gap is" : " fresh gaps are") + " on the chart;\na close through one of them would arm it." : "No gap on the chart yet.\nWaiting for a confirmed three-candle gap."
        string reqText = ""
        string optText = ""
        if sessionGate
            reqText := vJoin(reqText, "session " + vMark(factorSession, true))
        else
            optText := vJoin(optText, "session " + vMark(factorSession, true))
        if htfGate
            reqText := vJoin(reqText, "HTF gap " + vMark(factorContext, true))
        else
            optText := vJoin(optText, "HTF gap " + vMark(factorContext, true))
        if sweepGate
            reqText := vJoin(reqText, "sweep " + vMark(factorSweep, true))
        else
            optText := vJoin(optText, "sweep " + vMark(factorSweep, true))
        if smtGate
            reqText := vJoin(reqText, "SMT " + vMark(factorSMT, smtAvailable))
        else
            optText := vJoin(optText, "SMT " + vMark(factorSMT, smtAvailable))
        if biasGate
            reqText := vJoin(reqText, "day bias " + vMark(factorBias, true))
        else
            optText := vJoin(optText, "\n          day bias " + vMark(factorBias, true))
        if displacementGate
            reqText := vJoin(reqText, "displacement " + vMark(factorDisplacement, true))
        else
            optText := vJoin(optText, "displacement " + vMark(factorDisplacement, true))
        string whyText = "Required: " + (reqText == "" ? "none — every inversion close is taken" : reqText) + ".\nOptional: " + (optText == "" ? "none" : optText) + "."
        string sessClause = sessionGate ? " inside " + sess : ""
        string planText = paperActive ? "Stop " + vPx(paperStop) + " / target " + vPx(paperTarget) + " are fixed from the signal close.\nExit at stop, target, or after " + str.tostring(maxHoldBars) + " bars" + (closeAtSessionEnd ? ", or at session end." : ".") : dayLocked ? "No further entries today.\nContext keeps updating." : (entryMode == "Inversion close" ? "If a bar closes through a gap's far edge" + sessClause + " → enter at that close." : aDir != 0 ? "If a bar retests " + vPx(aLo) + "–" + vPx(aHi) + " and closes back " + (aDir == 1 ? "above" : "below") + " it" + sessClause + " → " + vSideLower(aDir) + " at that close." : "If a bar retests an inverted gap and closes back beyond it" + sessClause + " → enter at that close.") + "\nStop beyond the prior " + str.tostring(swingLookback) + "-bar swing + " + str.tostring(stopTicks) + " tick · target " + str.tostring(rewardR, "0.##") + "R · size from " + str.tostring(cashRisk, "0.##") + " " + syminfo.currency + "."
        string limitText = "Higher-frame context waits for the closed candle: delay by design.\nNo news, order book, or intrabar path.\nChecks are rules, not odds."
        string recordText = str.tostring(paperTrades) + " plans resolved on this chart · " + vNum(paperNetR) + "R before costs.\n" + (paperTrades < 30 ? "Small sample; a description of the past, not an edge." : "Loaded history only; no forward validation.")
        string fullText = "IFVG / CO-PILOT · " + heroTxt + "\nNOW  " + nowText
        if vNarr != "Brief" and vWhyOn
            fullText += "\nWHY  " + whyText
        if vNarr != "Brief" and vPlanOn
            fullText += "\nPLAN " + planText
        if vNarr == "Detailed" and vLimitsOn
            fullText += "\nLIMIT " + limitText + "\nRECORD " + recordText
        fullText += "\nContext and rules; not a prediction."
        if IS_STRATEGY
            if na(vStrategyNarr)
                vStrategyNarr := label.new(bar_index, high, "", xloc = xloc.bar_index, style = label.style_label_left)
            int nx = str.contains(vNarrPos, "Left") ? math.max(0, bar_index - 80) : bar_index + 3
            float ny = str.contains(vNarrPos, "Top") ? vNarrHigh + vAtr : str.contains(vNarrPos, "Bottom") ? vNarrLow - vAtr : (vNarrHigh + vNarrLow) * 0.5
            label.set_xy(vStrategyNarr, nx, ny)
            label.set_text(vStrategyNarr, fullText)
            label.set_color(vStrategyNarr, vBg)
            label.set_textcolor(vStrategyNarr, vText)
            label.set_size(vStrategyNarr, vNarrBody)
        else
            table.merge_cells(vNarrTable, 0, 0, 1, 0)
            vCell(vNarrTable, 0, 0, "◆ CO-PILOT / " + heroTxt, vAccent, vBand, size.small, text.align_left)
            vCell(vNarrTable, 0, 1, "NOW", vAccent, vBg, vNarrBody, text.align_left)
            vCell(vNarrTable, 1, 1, nowText, vText, vBg, vNarrBody, text.align_left)
            if vNarr != "Brief" and vWhyOn
                vCell(vNarrTable, 0, 2, "WHY", vAccent, vStripe, vNarrBody, text.align_left)
                vCell(vNarrTable, 1, 2, whyText, vText, vStripe, vNarrBody, text.align_left)
            if vNarr != "Brief" and vPlanOn
                vCell(vNarrTable, 0, 3, "PLAN", vAccent, vBg, vNarrBody, text.align_left)
                vCell(vNarrTable, 1, 3, planText, vText, vBg, vNarrBody, text.align_left)
            if vNarr == "Detailed" and vLimitsOn
                vCell(vNarrTable, 0, 4, "LIMIT", vAccent, vStripe, vNarrBody, text.align_left)
                vCell(vNarrTable, 1, 4, limitText, vMuted, vStripe, vNarrBody, text.align_left)
                vCell(vNarrTable, 0, 5, "RECORD", vAccent, vBg, vNarrBody, text.align_left)
                vCell(vNarrTable, 1, 5, recordText, vMuted, vBg, vNarrBody, text.align_left)
            table.merge_cells(vNarrTable, 0, 7, 1, 7)
            vCell(vNarrTable, 0, 7, "Context and rules; not a prediction.", vMuted, vBg, vNarrBody, text.align_left)
Download indicator .pine

The strategy companion

Use a separate chart, match engine settings and adapt costs to your instrument.

Companion code

Copy the code as it is. No coding needed.

View the full code
//@version=6
// Web revision display-fix.1: restore precise inversion-close and positive-close wording; no engine changes.
// IFVG Desk · original open-source implementation · MIT
// Built from Pine Builder skeleton architecture. See docs/build-card.md.
// Signals are close-confirmed. Paper R excludes costs; broker simulation differs.
// Broker defaults: 2.25 cash per contract/unit, 1 tick slippage, 5% margin. Verify market suitability.
strategy("IFVG Desk · Strategy Lab", "IFVG Lab", overlay = true, behind_chart = false, initial_capital = 100000, commission_type = strategy.commission.cash_per_contract, commission_value = 2.25, slippage = 1, process_orders_on_close = true, calc_on_every_tick = false, calc_on_order_fills = false, pyramiding = 0, margin_long = 5, margin_short = 5, max_boxes_count = 160, max_lines_count = 350, max_labels_count = 200, max_bars_back = 3000)
const bool IS_STRATEGY = true

// Original, mechanical interpretation of publicly described IFVG concepts.
// CLOSED-BAR SPINE: every lifecycle, signal, and paper mutation is confirmed-only.
const string NY_TZ = "America/New_York"
const string G_ENGINE = "01 · Gap engine"
const string G_CONTEXT = "02 · Higher-timeframe context"
const string G_CHECKS = "03 · Confluence gates"
const string G_RISK = "04 · Risk & paper record"
const string AUDIT_VERSION = "1.2.0"
const string G_AUDIT = "06 · Setup audit trail"

string entryMode = input.string("Inversion close", "Entry trigger", options = ["Inversion close", "Retest rejection"], group = G_ENGINE, tooltip = "Inversion: later close through opposite gap edge. Retest: a subsequent overlapping candle rejects beyond the IFVG edge in the trade direction. Failed gates do not create retroactive entries.", display = display.none)
int atrLen = input.int(14, "ATR length", minval = 2, group = G_ENGINE, display = display.none)
float minGapAtr = input.float(0.05, "Minimum gap / ATR", minval = 0, maxval = 3, step = 0.05, group = G_ENGINE, display = display.none)
float birthBodyAtr = input.float(0, "Middle candle body / ATR", minval = 0, maxval = 5, step = 0.1, group = G_ENGINE, tooltip = "Zero disables the gap-birth displacement filter. Uses the middle candle's confirmed ATR.", display = display.none)
int maxZoneAge = input.int(180, "Maximum zone age · chart bars", minval = 5, maxval = 2000, group = G_ENGINE, display = display.none)
int zoneCap = input.int(40, "Retained chart zones", minval = 5, maxval = 80, group = G_ENGINE, display = display.none)

string tfInputA = input.string("Auto", "Lane A timeframe", options = ["Auto", "1", "3", "5", "15", "30", "60", "120", "240", "D", "W"], group = G_CONTEXT, display = display.none)
string tfInputB = input.string("Auto", "Lane B timeframe", options = ["Auto", "1", "3", "5", "15", "30", "60", "120", "240", "D", "W"], group = G_CONTEXT, display = display.none)
string tfInputC = input.string("Auto", "Lane C timeframe", options = ["Auto", "1", "3", "5", "15", "30", "60", "120", "240", "D", "W"], group = G_CONTEXT, tooltip = "Auto resolves to 5/15/30 on 1m and 3m; 15/30/60 on 5m. All three lanes must be strictly increasing and higher than chart, including disabled lanes.", display = display.none)
bool laneOnA = input.bool(true, "Enable lane A", group = G_CONTEXT, display = display.none)
bool laneOnB = input.bool(true, "Enable lane B", group = G_CONTEXT, display = display.none)
bool laneOnC = input.bool(true, "Enable lane C", group = G_CONTEXT, display = display.none)
int htfMaxAge = input.int(80, "Context age · source bars", minval = 3, maxval = 500, group = G_CONTEXT, display = display.none)
bool htfGate = input.bool(false, "Require aligned HTF gap overlap", group = G_CHECKS, tooltip = "Our definition: signal candle overlaps a still-valid same-direction FVG in any enabled higher-timeframe lane. A confluence count is not a probability.", display = display.none)
bool sweepGate = input.bool(false, "Require recent liquidity sweep", group = G_CHECKS, display = display.none)
int sweepLookback = input.int(20, "Sweep reference · prior bars", minval = 2, maxval = 200, group = G_CHECKS, display = display.none)
int sweepWindow = input.int(10, "Sweep remains recent · bars", minval = 0, maxval = 100, group = G_CHECKS, display = display.none)
bool displacementGate = input.bool(false, "Require entry candle displacement", group = G_CHECKS, display = display.none)
float displacementAtr = input.float(0.5, "Entry body / ATR", minval = 0.1, maxval = 5, step = 0.1, group = G_CHECKS, display = display.none)
bool biasGate = input.bool(false, "Require prior-day candle bias", group = G_CHECKS, display = display.none)
bool smtGate = input.bool(false, "Require synchronized SMT", group = G_CHECKS, display = display.none)
string smtSymbol = input.symbol("CME_MINI:ES1!", "SMT comparison symbol", group = G_CHECKS, display = display.none)
string smtRelationship = input.string("Direct", "SMT relationship", options = ["Direct", "Inverse"], group = G_CHECKS, display = display.none)
int smtLookback = input.int(10, "SMT prior-bar reference length", minval = 2, maxval = 100, group = G_CHECKS, tooltip = "Chart prior bar makes a new extreme versus its previous N bars; synchronized comparison prior bar does not confirm. Inverse relationship swaps comparator high/low. Missing or stale data never passes.", display = display.none)
bool sessionGate = input.bool(true, "Require NY entry session", group = G_CHECKS, display = display.none)
string tradeSession = input.session("0930-1100", "Entry session · New York", group = G_CHECKS, display = display.none)

float cashRisk = input.float(100, "Planned cash risk · symbol currency", minval = 1, maxval = 1000000, group = G_RISK, display = display.none)
float rewardR = input.float(1, "Target / initial risk", minval = 0.25, maxval = 10, step = 0.25, group = G_RISK, display = display.none)
int swingLookback = input.int(10, "Stop swing · prior bars", minval = 2, maxval = 200, group = G_RISK, display = display.none)
int stopTicks = input.int(1, "Stop buffer · ticks", minval = 0, maxval = 100, group = G_RISK, display = display.none)
float qtyStep = input.float(1, "Quantity step", minval = 0.000001, group = G_RISK, tooltip = "Quantity = floor(cash risk / (stop distance × point value) / step) × step. A zero estimate blocks entries. Uses symbol currency; no FX conversion. Fractional contracts may not be executable.", display = display.none)
int maxTradesDay = input.int(1, "Resolved paper trades / NY day", minval = 1, maxval = 100, group = G_RISK, display = display.none)
int maxHoldBars = input.int(120, "Timeout · bars after entry", minval = 1, maxval = 5000, group = G_RISK, display = display.none)
bool closeAtSessionEnd = input.bool(false, "Close on first bar outside session", group = G_RISK, display = display.none)
bool sendJsonAlerts = input.bool(true, "Send structured entry alerts", group = "05 · Alerts", tooltip = "Create a TradingView alert using Any alert() function call. Paper plans, not broker execution instructions. One confirmed entry per signal; payload includes deterministic symbol/frame/time/zone ID.", display = display.none)
bool auditEnabled = input.bool(true, "Record setup history", group = G_AUDIT, tooltip = "Records confirmed gap, inversion, retest, entry decision and terminal events as frozen snapshots. Independent of display settings. Recalculating rebuilds history from the loaded bars and current settings.", display = display.none)
int auditCap = input.int(300, "Retained audit events", minval = 50, maxval = 500, group = G_AUDIT, tooltip = "Rolling event limit across all setups. Old events can be removed; the history panel and report say how many. This is not a permanent archive.", display = display.none)
string auditSetupFilter = input.string("", "Setup ID filter · blank = all", group = G_AUDIT, tooltip = "Paste the full Setup ID from a history-row tooltip or Pine Logs. Filters the panel and report, not recording or trading decisions.", display = display.none)
bool auditLogExport = input.bool(false, "Write bug report to Pine Logs", group = G_AUDIT, tooltip = "For an editable personal copy: writes settings and retained filtered snapshots after historical calculation, then matching confirmed realtime events. Copy the JSON lines from Pine Logs. Published scripts do not expose Pine Logs.", display = display.none)

type Zone
    int id
    int dir
    int phase
    float hi
    float lo
    int bornBar
    int bornTime
    int flipBar
    int flipTime
    int endTime

type HGap
    int dir
    float hi
    float lo
    int age

type Event
    int stamp
    string message
    int dir

// Value-only records: no Zone/HGap references can rewrite a recorded explanation.
type AuditEvent
    int stamp
    string setupId
    string kind
    string reason
    int dir
    string payload
    string summary

var array<Zone> zones = array.new<Zone>()
var array<HGap> laneA = array.new<HGap>()
var array<HGap> laneB = array.new<HGap>()
var array<HGap> laneC = array.new<HGap>()
var array<Event> eventTape = array.new<Event>()
var array<AuditEvent> auditTrail = array.new<AuditEvent>()
var array<int> auditDropped = array.new<int>(1, 0)

f_event(string msg, int direction) =>
    array.unshift(eventTape, Event.new(time_close, msg, direction))
    if array.size(eventTape) > 6
        array.pop(eventTape)

f_nextTf(float seconds) =>
    seconds < 300 ? "5" : seconds < 900 ? "15" : seconds < 1800 ? "30" : seconds < 3600 ? "60" : seconds < 7200 ? "120" : seconds < 14400 ? "240" : seconds < 86400 ? "D" : seconds < 604800 ? "W" : "M"

f_tickOut(float px, int direction, bool target) =>
    int roundingDirection = target ? direction : -direction
    float ticks = px / syminfo.mintick
    math.round_to_mintick((roundingDirection == 1 ? math.ceil(ticks - 0.000000001) : math.floor(ticks + 0.000000001)) * syminfo.mintick)

// Some feeds publish sub-tick OHLC values. Preserve them; snap only arithmetic noise.
f_priceDistance(float fromPrice, float toPrice, int direction) =>
    float rawDistance = (toPrice - fromPrice) * direction
    float rawTicks = rawDistance / syminfo.mintick
    float nearestTicks = math.round(rawTicks)
    float tickTolerance = 8 * 2.220446049250313e-16 * math.max(1, math.max(math.abs(fromPrice), math.abs(toPrice)) / syminfo.mintick)
    bool snapNoise = math.abs(rawTicks - nearestTicks) / tickTolerance <= 1
    float distance = snapNoise ? nearestTicks * syminfo.mintick : rawDistance
    float ticks = snapNoise ? nearestTicks : rawTicks
    [distance, ticks]

// Round only machine-level noise at a quantity-step boundary, never an actual shortfall.
f_riskQuantity(float distance) =>
    float qty = 0
    if not na(distance) and distance > 0 and syminfo.pointvalue > 0
        float unitRisk = distance * syminfo.pointvalue
        float rawSteps = cashRisk / unitRisk / qtyStep
        float nearestSteps = math.round(rawSteps)
        float stepTolerance = 8 * 2.220446049250313e-16 * math.max(1, math.abs(rawSteps))
        // Pine rounds float comparison operands to 9 decimals; compare scaled errors.
        float normalizedSteps = math.abs(rawSteps - nearestSteps) / stepTolerance <= 1 ? nearestSteps : rawSteps
        float wholeSteps = math.floor(normalizedSteps)
        qty := wholeSteps * qtyStep
        float totalRisk = qty * unitRisk
        float budgetTolerance = 8 * 2.220446049250313e-16 * math.max(math.abs(cashRisk), math.abs(totalRisk))
        if (totalRisk - cashRisk) / budgetTolerance > 1
            qty := math.max(0, wholeSteps - 1) * qtyStep
    qty

string tfA = tfInputA == "Auto" ? f_nextTf(timeframe.in_seconds()) : tfInputA
string tfB = tfInputB == "Auto" ? f_nextTf(timeframe.in_seconds(tfA)) : tfInputB
string tfC = tfInputC == "Auto" ? f_nextTf(timeframe.in_seconds(tfB)) : tfInputC
if barstate.isfirst
    if not chart.is_standard or not timeframe.isintraday
        runtime.error("IFVG Desk needs standard time-based intraday candles. Start with 1m, 3m or 5m.")
    if timeframe.in_seconds(tfA) <= timeframe.in_seconds() or timeframe.in_seconds(tfB) <= timeframe.in_seconds(tfA) or timeframe.in_seconds(tfC) <= timeframe.in_seconds(tfB)
        runtime.error("Context frames must satisfy chart < A < B < C. Choose Auto in all three lanes, or increase the manual frames.")
    if htfGate and not (laneOnA or laneOnB or laneOnC)
        runtime.error("The HTF gate needs at least one enabled lane. Enable a lane or turn off the HTF gate.")

// Every tuple member is shifted at source: no developing HTF values or backfill.
[stampA, closeA, highA, lowA, oldHighA, oldLowA, atrA, bodyA, middleAtrA] = request.security(syminfo.tickerid, tfA, [time[1], close[1], high[1], low[1], high[3], low[3], ta.atr(atrLen)[1], math.abs(close[2] - open[2]), ta.atr(atrLen)[2]], lookahead = barmerge.lookahead_on)
[stampB, closeB, highB, lowB, oldHighB, oldLowB, atrB, bodyB, middleAtrB] = request.security(syminfo.tickerid, tfB, [time[1], close[1], high[1], low[1], high[3], low[3], ta.atr(atrLen)[1], math.abs(close[2] - open[2]), ta.atr(atrLen)[2]], lookahead = barmerge.lookahead_on)
[stampC, closeC, highC, lowC, oldHighC, oldLowC, atrC, bodyC, middleAtrC] = request.security(syminfo.tickerid, tfC, [time[1], close[1], high[1], low[1], high[3], low[3], ta.atr(atrLen)[1], math.abs(close[2] - open[2]), ta.atr(atrLen)[2]], lookahead = barmerge.lookahead_on)
[pdh, pdl, pdOpen, pdClose] = request.security(syminfo.tickerid, "D", [high[1], low[1], open[1], close[1]], lookahead = barmerge.lookahead_on)
[smtStamp, smtHigh, smtLow, smtPriorHigh, smtPriorLow, smtOldestHigh, smtOldestLow] = request.security(smtSymbol, timeframe.period, [time[1], high[1], low[1], ta.highest(high[2], smtLookback), ta.lowest(low[2], smtLookback), high[smtLookback + 1], low[smtLookback + 1]], gaps = barmerge.gaps_on, lookahead = barmerge.lookahead_on, ignore_invalid_symbol = true)

float atrValue = ta.atr(atrLen)
// Read history every calculation before conditional snapshot calls.
float auditMiddleBody = math.abs(close[1] - open[1])
float auditMiddleAtr = atrValue[1]
float auditOldHigh = high[2]
float auditOldLow = low[2]
float auditPriorBarHigh = high[1]
float auditPriorBarLow = low[1]
float sweepPriorLow = ta.lowest(low, sweepLookback)[1]
float sweepPriorHigh = ta.highest(high, sweepLookback)[1]
bool sweepBullNow = not na(low[sweepLookback]) and low < sweepPriorLow and close > sweepPriorLow
bool sweepBearNow = not na(high[sweepLookback]) and high > sweepPriorHigh and close < sweepPriorHigh
int barsSinceBullSweep = ta.barssince(sweepBullNow)
int barsSinceBearSweep = ta.barssince(sweepBearNow)
bool recentBullSweep = not na(barsSinceBullSweep) and barsSinceBullSweep <= sweepWindow
bool recentBearSweep = not na(barsSinceBearSweep) and barsSinceBearSweep <= sweepWindow
float ownPriorHigh = ta.highest(high[2], smtLookback)
float ownPriorLow = ta.lowest(low[2], smtLookback)
float swingLow = ta.lowest(low, swingLookback)[1]
float swingHigh = ta.highest(high, swingLookback)[1]
bool smtAvailableNow = not na(smtStamp) and smtStamp == time[1] and not na(smtHigh) and not na(smtLow) and not na(smtOldestHigh) and not na(smtOldestLow) and not na(high[smtLookback + 1]) and not na(low[smtLookback + 1]) and not na(smtPriorHigh) and not na(smtPriorLow) and not na(ownPriorHigh) and not na(ownPriorLow)
bool smtBullNow = smtAvailableNow and low[1] < ownPriorLow and (smtRelationship == "Direct" ? smtLow >= smtPriorLow : smtHigh <= smtPriorHigh)
bool smtBearNow = smtAvailableNow and high[1] > ownPriorHigh and (smtRelationship == "Direct" ? smtHigh <= smtPriorHigh : smtLow >= smtPriorLow)
int pdBias = pdClose > pdOpen ? 1 : pdClose < pdOpen ? -1 : 0
bool inSession = not na(time(timeframe.period, tradeSession, NY_TZ))
// Anchor each session to its scheduled NY start, even when the feed omits outside bars.
int sessionStartHour = int(str.tonumber(str.substring(tradeSession, 0, 2)))
int sessionStartMinute = int(str.tonumber(str.substring(tradeSession, 2, 4)))
int minutesIntoNyDay = hour(time, NY_TZ) * 60 + minute(time, NY_TZ)
int sessionAnchorDay = dayofmonth(time, NY_TZ) - (minutesIntoNyDay < sessionStartHour * 60 + sessionStartMinute ? 1 : 0)
int sessionInstanceStamp = timestamp(NY_TZ, year(time, NY_TZ), month(time, NY_TZ), sessionAnchorDay, sessionStartHour, sessionStartMinute)
bool sessionStart = inSession and (not inSession[1] or sessionInstanceStamp != sessionInstanceStamp[1])
bool sessionEnd = not inSession and inSession[1]
int nyDay = year(time_close, NY_TZ) * 10000 + month(time_close, NY_TZ) * 100 + dayofmonth(time_close, NY_TZ)
bool newNyDay = bar_index == 0 or nyDay != nyDay[1]
bool openBar = hour(time, NY_TZ) == 9 and minute(time, NY_TZ) == 30

f_updateLane(array<HGap> lane, float sourceClose, float sourceHigh, float sourceLow, float oldHigh, float oldLow, float sourceAtr, float middleBody, float middleAtr) =>
    int i = array.size(lane) - 1
    while i >= 0
        HGap g = array.get(lane, i)
        g.age += 1
        bool broken = g.dir == 1 ? sourceClose < g.lo : sourceClose > g.hi
        if broken or g.age > htfMaxAge
            array.remove(lane, i)
        i -= 1
    bool bodyOk = birthBodyAtr == 0 or middleBody >= middleAtr * birthBodyAtr
    bool bull = sourceLow > oldHigh and (minGapAtr == 0 or sourceLow - oldHigh >= sourceAtr * minGapAtr) and bodyOk
    bool bear = sourceHigh < oldLow and (minGapAtr == 0 or oldLow - sourceHigh >= sourceAtr * minGapAtr) and bodyOk
    if bull or bear
        array.push(lane, HGap.new(bull ? 1 : -1, bull ? sourceLow : oldLow, bull ? oldHigh : sourceHigh, 0))
    if array.size(lane) > 12
        array.shift(lane)

f_overlap(array<HGap> lane, int direction) =>
    bool found = false
    if array.size(lane) > 0
        for i = 0 to array.size(lane) - 1
            HGap g = array.get(lane, i)
            if g.dir == direction and high >= g.lo and low <= g.hi
                found := true
    found

f_context(int direction) =>
    bool a = f_overlap(laneA, direction)
    bool b = f_overlap(laneB, direction)
    bool c = f_overlap(laneC, direction)
    direction != 0 and ((laneOnA and a) or (laneOnB and b) or (laneOnC and c))

f_laneSummary(array<HGap> lane, bool enabled) =>
    int direction = 0
    float hi = na
    float lo = na
    float distance = na
    string state = enabled ? "EMPTY" : "OFF"
    float closedPrice = barstate.isconfirmed ? close : close[1]
    if enabled and array.size(lane) > 0
        for i = 0 to array.size(lane) - 1
            HGap g = array.get(lane, i)
            float dist = closedPrice > g.hi ? closedPrice - g.hi : closedPrice < g.lo ? g.lo - closedPrice : 0
            if na(distance) or dist <= distance
                distance := dist
                direction := g.dir
                hi := g.hi
                lo := g.lo
        state := distance == 0 ? "IN GAP" : "NEARBY"
    [direction, hi, lo, distance, state]

f_gates(int direction) =>
    bool contextOk = f_context(direction)
    bool sweepOk = direction == 1 ? recentBullSweep : direction == -1 ? recentBearSweep : false
    bool smtOk = direction == 1 ? smtBullNow : direction == -1 ? smtBearNow : false
    bool biasOk = direction != 0 and pdBias == direction
    bool displacementOk = math.abs(close - open) >= atrValue * displacementAtr and (direction == 1 ? close > open : direction == -1 ? close < open : false)
    bool allowed = (not htfGate or contextOk) and (not sweepGate or sweepOk) and (not smtGate or smtOk) and (not biasGate or biasOk) and (not displacementGate or displacementOk) and (not sessionGate or inSession)
    string reason = sessionGate and not inSession ? "Outside NY entry session" : htfGate and not contextOk ? "Need aligned HTF overlap" : sweepGate and not sweepOk ? "Need recent liquidity sweep" : smtGate and not smtOk ? (smtAvailableNow ? "Need SMT divergence" : "SMT unavailable / stale") : biasGate and not biasOk ? "Prior-day bias disagrees" : displacementGate and not displacementOk ? "Need directional displacement" : "Checks passed"
    [allowed, reason, contextOk, sweepOk, smtOk, biasOk, displacementOk]

f_riskPlan(int direction) =>
    float stop = f_tickOut(direction == 1 ? swingLow - stopTicks * syminfo.mintick : swingHigh + stopTicks * syminfo.mintick, direction, false)
    [distance, riskTicks] = f_priceDistance(stop, close, direction)
    float target = f_tickOut(close + direction * distance * rewardR, direction, true)
    [targetDistance, targetTicks] = f_priceDistance(close, target, direction)
    float qty = f_riskQuantity(distance)
    bool valid = bar_index >= swingLookback and not na(stop) and math.floor(riskTicks) >= 1 and math.floor(targetTicks) >= 1 and qty > 0
    [stop, target, distance, qty, valid]

f_jsonNumber(float value) =>
    na(value) ? "null" : str.tostring(value, "0.########")

f_jsonPrice(float value) =>
    na(value) ? "null" : str.tostring(value, format.mintick)

f_jsonEntry(float value) =>
    na(value) ? "null" : str.tostring(value, "0.################")

f_jsonBool(bool value) =>
    value ? "true" : "false"

f_jsonString(string value) =>
    string escaped = str.replace_all(str.replace_all(value, "\\", "\\\\"), "\"", "\\\"")
    escaped := str.replace_all(str.replace_all(escaped, "\n", "\\n"), "\t", "\\t")
    // Pine supports newline/tab escapes, but not a carriage-return literal escape.
    // Match any remaining JSON control characters by their regex code points.
    if str.length(str.match(escaped, "[\\x00-\\x1F]")) > 0
        string hex = "0123456789abcdef"
        for code = 0 to 31
            string suffix = (code < 16 ? "0" : "1") + str.substring(hex, code % 16, code % 16 + 1)
            string control = str.match(escaped, "\\x" + suffix)
            if str.length(control) > 0
                escaped := str.replace_all(escaped, control, "\\u00" + suffix)
    "\"" + escaped + "\""

var bool paperActive = false
var int paperDir = 0
var float paperEntry = na
var float paperStop = na
var float paperTarget = na
var float paperQty = na
var float paperRisk = na
var int paperEntryBar = na
var int paperEntryTime = na
// Detached from the bounded zones array, so later evictions cannot lose exit identity.
var Zone paperSetup = na
var int paperTrades = 0
var int paperWins = 0
var int paperLosses = 0
var int paperAmbiguous = 0
var float paperNetR = 0
var float paperLastR = na
var float paperExitPrice = na
var string paperExitReason = "No resolved sample"
var int dayResolved = 0
var float dayNetR = 0
var bool dayLocked = false
var float nyOpen = na
var int nyOpenTime = na
var float sessionHi = na
var float sessionLo = na
var string deskReason = "Wait for a confirmed three-candle gap"
var int analysisDir = 0
var bool factorContext = false
var bool factorSweep = false
var bool factorSMT = false
var bool factorBias = false
var bool factorDisplacement = false
var bool factorSession = false
var bool smtAvailable = false
var int lastStampA = na
var int lastStampB = na
var int lastStampC = na

bool newGap = false
bool newInversion = false
bool newInvalidation = false
bool newExpiry = false
bool newLong = false
bool newShort = false
bool paperClosed = false
int signalDir = 0
int signalZoneId = na
float signalEntry = na
float signalStop = na
float signalTarget = na
float signalQty = na

f_setupId(Zone z) =>
    syminfo.tickerid + "|" + timeframe.period + "|" + str.tostring(z.bornTime) + "|" + (z.dir == 1 ? "bull" : "bear")

f_auditSettings() =>
    string s = "{\"entry_mode\":" + f_jsonString(entryMode) + ",\"atr_length\":" + str.tostring(atrLen) + ",\"min_gap_atr\":" + f_jsonEntry(minGapAtr) + ",\"birth_body_atr\":" + f_jsonEntry(birthBodyAtr) + ",\"max_zone_age\":" + str.tostring(maxZoneAge) + ",\"zone_cap\":" + str.tostring(zoneCap)
    s += ",\"htf_input\":[" + f_jsonString(tfInputA) + "," + f_jsonString(tfInputB) + "," + f_jsonString(tfInputC) + "],\"htf_resolved\":[" + f_jsonString(tfA) + "," + f_jsonString(tfB) + "," + f_jsonString(tfC) + "],\"lanes_enabled\":[" + f_jsonBool(laneOnA) + "," + f_jsonBool(laneOnB) + "," + f_jsonBool(laneOnC) + "],\"htf_max_age\":" + str.tostring(htfMaxAge)
    s += ",\"gates\":{\"context\":" + f_jsonBool(htfGate) + ",\"sweep\":" + f_jsonBool(sweepGate) + ",\"smt\":" + f_jsonBool(smtGate) + ",\"bias\":" + f_jsonBool(biasGate) + ",\"displacement\":" + f_jsonBool(displacementGate) + ",\"session\":" + f_jsonBool(sessionGate) + "}"
    s += ",\"sweep_lookback\":" + str.tostring(sweepLookback) + ",\"sweep_window\":" + str.tostring(sweepWindow) + ",\"displacement_atr\":" + f_jsonEntry(displacementAtr) + ",\"smt_symbol\":" + f_jsonString(smtSymbol) + ",\"smt_relationship\":" + f_jsonString(smtRelationship) + ",\"smt_lookback\":" + str.tostring(smtLookback) + ",\"entry_session\":" + f_jsonString(tradeSession) + ",\"timezone\":" + f_jsonString(NY_TZ)
    s += ",\"cash_risk\":" + f_jsonEntry(cashRisk) + ",\"reward_r\":" + f_jsonEntry(rewardR) + ",\"swing_lookback\":" + str.tostring(swingLookback) + ",\"stop_ticks\":" + str.tostring(stopTicks) + ",\"quantity_step\":" + f_jsonEntry(qtyStep) + ",\"max_trades_day\":" + str.tostring(maxTradesDay) + ",\"max_hold_bars\":" + str.tostring(maxHoldBars) + ",\"close_at_session_end\":" + f_jsonBool(closeAtSessionEnd)
    s += ",\"audit_enabled\":" + f_jsonBool(auditEnabled) + ",\"audit_cap\":" + str.tostring(auditCap) + ",\"entry_json_alerts\":" + f_jsonBool(sendJsonAlerts) + "}"
    s

// Settings are constant within a calculation. Export once alongside the event lines.
var string auditSettings = f_auditSettings()
var int auditDatasetStart = time

f_auditLane(array<HGap> lane, int direction, bool enabled, int stamp) =>
    string matches = ""
    if array.size(lane) > 0
        for g in lane
            if g.dir == direction and high >= g.lo and low <= g.hi
                matches += (matches == "" ? "" : ",") + "{\"low\":" + f_jsonEntry(g.lo) + ",\"high\":" + f_jsonEntry(g.hi) + ",\"age\":" + str.tostring(g.age) + "}"
    "{\"enabled\":" + f_jsonBool(enabled) + ",\"source_open_ms\":" + f_jsonNumber(stamp) + ",\"aligned_overlaps\":[" + matches + "]}"

f_auditRiskReason(int direction) =>
    [stop, target, distance, qty, valid] = f_riskPlan(direction)
    [stopDistance, riskTicks] = f_priceDistance(stop, close, direction)
    [targetDistance, targetTicks] = f_priceDistance(close, target, direction)
    string why = ""
    why += bar_index < swingLookback or na(stop) ? "Stop history unavailable; " : ""
    why += not na(riskTicks) and math.floor(riskTicks) < 1 ? "Stop distance below one tick or wrong side; " : ""
    why += not na(targetTicks) and math.floor(targetTicks) < 1 ? "Target distance below one tick or wrong side; " : ""
    why += qty <= 0 ? "Quantity estimate is zero; " : ""
    why

f_auditGateReason(int direction) =>
    [allowed, reason, cx, sw, sm, bi, dp] = f_gates(direction)
    string why = ""
    why += sessionGate and not inSession ? "Outside NY entry session; " : ""
    why += htfGate and not cx ? "Need aligned HTF overlap; " : ""
    why += sweepGate and not sw ? "Need recent liquidity sweep; " : ""
    why += smtGate and not sm ? (smtAvailableNow ? "Need SMT divergence; " : "SMT unavailable / stale; ") : ""
    why += biasGate and not bi ? "Prior-day bias disagrees; " : ""
    why += displacementGate and not dp ? "Need directional displacement; " : ""
    why + f_auditRiskReason(direction)

// Called at the decision site, only on confirmed bars. Serialize NOW, never in a renderer.
f_auditRecord(Zone z, string kind, string reason, string detail) =>
    if auditEnabled and barstate.isconfirmed
        int direction = -z.dir
        string setupId = f_setupId(z)
        [allowed, gateReason, cx, sw, sm, bi, dp] = f_gates(direction)
        [plannedStop, plannedTarget, distance, qty, riskOk] = f_riskPlan(direction)
        bool overlaps = low <= z.hi and high >= z.lo
        bool rejects = direction == 1 ? close > z.hi and close > open : close < z.lo and close < open
        string payload = "{\"schema\":\"ifvg-desk.audit.v1\",\"version\":" + f_jsonString(AUDIT_VERSION) + ",\"setup_id\":" + f_jsonString(setupId) + ",\"event_id\":" + f_jsonString(setupId + "|" + str.tostring(time_close) + "|" + kind) + ",\"event\":" + f_jsonString(kind) + ",\"reason\":" + f_jsonString(reason)
        payload += ",\"symbol\":" + f_jsonString(syminfo.tickerid) + ",\"timeframe\":" + f_jsonString(timeframe.period) + ",\"bar_open_ms\":" + str.tostring(time) + ",\"bar_close_ms\":" + str.tostring(time_close) + ",\"confirmed\":true,\"costs_included\":false,\"ohlc\":[" + f_jsonEntry(open) + "," + f_jsonEntry(high) + "," + f_jsonEntry(low) + "," + f_jsonEntry(close) + "]"
        payload += ",\"gap\":{\"zone_id\":" + str.tostring(z.id) + ",\"direction\":" + str.tostring(z.dir) + ",\"trade_direction\":" + str.tostring(direction) + ",\"low\":" + f_jsonEntry(z.lo) + ",\"high\":" + f_jsonEntry(z.hi) + ",\"born_open_ms\":" + str.tostring(z.bornTime) + ",\"flip_close_ms\":" + f_jsonNumber(z.flipTime) + ",\"phase\":" + str.tostring(z.phase) + ",\"age_bars\":" + str.tostring(bar_index - z.bornBar) + "}"
        payload += ",\"trigger\":{\"mode\":" + f_jsonString(entryMode) + ",\"inversion_this_bar\":" + f_jsonBool(not na(z.flipBar) and z.flipBar == bar_index) + ",\"later_than_flip\":" + f_jsonBool(not na(z.flipBar) and bar_index > z.flipBar) + ",\"overlaps\":" + f_jsonBool(overlaps) + ",\"directional_rejection\":" + f_jsonBool(rejects) + "}"
        payload += ",\"factors\":{\"context\":" + f_jsonBool(cx) + ",\"sweep\":" + f_jsonBool(sw) + ",\"smt\":" + f_jsonBool(sm) + ",\"smt_available\":" + f_jsonBool(smtAvailableNow) + ",\"bias\":" + f_jsonBool(bi) + ",\"displacement\":" + f_jsonBool(dp) + ",\"session\":" + f_jsonBool(inSession) + ",\"gates_passed\":" + f_jsonBool(allowed) + "}"
        payload += ",\"evidence\":{\"atr\":" + f_jsonEntry(atrValue) + ",\"middle_body\":" + f_jsonEntry(auditMiddleBody) + ",\"middle_atr\":" + f_jsonEntry(auditMiddleAtr) + ",\"two_bars_ago_high\":" + f_jsonEntry(auditOldHigh) + ",\"two_bars_ago_low\":" + f_jsonEntry(auditOldLow) + ",\"sweep_reference\":" + f_jsonEntry(direction == 1 ? sweepPriorLow : sweepPriorHigh) + ",\"bars_since_sweep\":" + f_jsonNumber(direction == 1 ? barsSinceBullSweep : barsSinceBearSweep) + ",\"prior_day_open\":" + f_jsonEntry(pdOpen) + ",\"prior_day_close\":" + f_jsonEntry(pdClose)
        payload += ",\"smt_source_open_ms\":" + f_jsonNumber(smtStamp) + ",\"own_prior_bar_high\":" + f_jsonEntry(auditPriorBarHigh) + ",\"own_prior_bar_low\":" + f_jsonEntry(auditPriorBarLow) + ",\"own_prior_high\":" + f_jsonEntry(ownPriorHigh) + ",\"own_prior_low\":" + f_jsonEntry(ownPriorLow) + ",\"comparator_high\":" + f_jsonEntry(smtHigh) + ",\"comparator_low\":" + f_jsonEntry(smtLow) + ",\"comparator_prior_high\":" + f_jsonEntry(smtPriorHigh) + ",\"comparator_prior_low\":" + f_jsonEntry(smtPriorLow)
        payload += ",\"htf_lanes\":[" + f_auditLane(laneA, direction, laneOnA, stampA) + "," + f_auditLane(laneB, direction, laneOnB, stampB) + "," + f_auditLane(laneC, direction, laneOnC, stampC) + "]}"
        payload += ",\"candidate_plan\":{\"entry\":" + f_jsonEntry(close) + ",\"stop\":" + f_jsonEntry(plannedStop) + ",\"target\":" + f_jsonEntry(plannedTarget) + ",\"risk_distance\":" + f_jsonEntry(distance) + ",\"quantity\":" + f_jsonEntry(qty) + ",\"valid\":" + f_jsonBool(riskOk) + "},\"detail\":" + detail + "}"
        string summary = "Gap " + f_jsonEntry(z.lo) + "–" + f_jsonEntry(z.hi) + " · close " + f_jsonEntry(close) + " · overlap " + f_jsonBool(overlaps) + " · reject " + f_jsonBool(rejects) + "\n" + reason
        array.push(auditTrail, AuditEvent.new(time_close, setupId, kind, reason, direction, payload, summary))
        if array.size(auditTrail) > auditCap
            array.shift(auditTrail)
            array.set(auditDropped, 0, array.get(auditDropped, 0) + 1)
        if auditLogExport and barstate.isrealtime and (auditSetupFilter == "" or auditSetupFilter == setupId)
            log.info("{0}", payload)
    array.size(auditTrail)

if barstate.isconfirmed
    // Reset before exits: a trade spanning midnight is resolved on its NY exit date.
    if newNyDay
        dayResolved := 0
        dayNetR := 0
        dayLocked := false
        nyOpen := na
        nyOpenTime := na
        // Overnight custom sessions keep one continuous range across NY midnight.
        if not inSession or sessionStart
            sessionHi := na
            sessionLo := na
    if openBar
        nyOpen := open
        nyOpenTime := time
    if inSession
        sessionHi := sessionStart or na(sessionHi) ? high : math.max(sessionHi, high)
        sessionLo := sessionStart or na(sessionLo) ? low : math.min(sessionLo, low)

    if not na(stampA) and (na(lastStampA) or stampA != lastStampA)
        f_updateLane(laneA, closeA, highA, lowA, oldHighA, oldLowA, atrA, bodyA, middleAtrA)
        lastStampA := stampA
    if not na(stampB) and (na(lastStampB) or stampB != lastStampB)
        f_updateLane(laneB, closeB, highB, lowB, oldHighB, oldLowB, atrB, bodyB, middleAtrB)
        lastStampB := stampB
    if not na(stampC) and (na(lastStampC) or stampC != lastStampC)
        f_updateLane(laneC, closeC, highC, lowC, oldHighC, oldLowC, atrC, bodyC, middleAtrC)
        lastStampC := stampC

    // Close-based entries cannot be stopped by price that occurred before entry.
    if paperActive and bar_index > paperEntryBar
        bool stopGap = paperDir == 1 ? open <= paperStop : open >= paperStop
        bool targetGap = paperDir == 1 ? open >= paperTarget : open <= paperTarget
        bool stopTouch = paperDir == 1 ? low <= paperStop : high >= paperStop
        bool targetTouch = paperDir == 1 ? high >= paperTarget : low <= paperTarget
        bool timedOut = bar_index - paperEntryBar >= maxHoldBars
        bool sessionClose = closeAtSessionEnd and not inSession
        float exitPx = na
        string why = ""
        if stopGap
            exitPx := open
            why := "GAP STOP"
        else if targetGap
            exitPx := paperTarget
            why := "TARGET"
        else if stopTouch
            exitPx := paperStop
            why := targetTouch ? "AMBIGUOUS → STOP" : "STOP"
            if targetTouch
                paperAmbiguous += 1
        else if targetTouch
            exitPx := paperTarget
            why := "TARGET"
        else if timedOut or sessionClose
            exitPx := close
            why := sessionClose ? "SESSION CLOSE" : "TIMEOUT"
        if not na(exitPx)
            paperLastR := (exitPx - paperEntry) * paperDir / paperRisk
            paperExitPrice := exitPx
            paperExitReason := why
            paperTrades += 1
            paperWins += paperLastR > 0 ? 1 : 0
            paperLosses += paperLastR < 0 ? 1 : 0
            paperNetR += paperLastR
            dayResolved += 1
            dayNetR += paperLastR
            dayLocked := dayResolved >= maxTradesDay
            paperActive := false
            paperClosed := true
            f_event(why + " · " + str.tostring(paperLastR, "#.##") + "R", paperDir)
            f_auditRecord(paperSetup, "paper_resolved", why, "{\"entry_close_ms\":" + str.tostring(paperEntryTime) + ",\"entry\":" + f_jsonEntry(paperEntry) + ",\"stop\":" + f_jsonEntry(paperStop) + ",\"target\":" + f_jsonEntry(paperTarget) + ",\"quantity\":" + f_jsonEntry(paperQty) + ",\"risk_distance\":" + f_jsonEntry(paperRisk) + ",\"exit\":" + f_jsonEntry(exitPx) + ",\"result_r\":" + f_jsonEntry(paperLastR) + ",\"stop_gap\":" + f_jsonBool(stopGap) + ",\"target_gap\":" + f_jsonBool(targetGap) + ",\"stop_touch\":" + f_jsonBool(stopTouch) + ",\"target_touch\":" + f_jsonBool(targetTouch) + ",\"timed_out\":" + f_jsonBool(timedOut) + ",\"session_close\":" + f_jsonBool(sessionClose) + "}")

    // Existing gaps only: a newly born zone can never invert on its own birth bar.
    if array.size(zones) > 0
        for i = 0 to array.size(zones) - 1
            Zone z = array.get(zones, i)
            if z.phase <= 1
                if bar_index - z.bornBar > maxZoneAge
                    int previousPhase = z.phase
                    z.phase := 4
                    z.endTime := time_close
                    newExpiry := true
                    f_auditRecord(z, "expired", "Zone age exceeded " + str.tostring(maxZoneAge) + " chart bars", "{\"phase_from\":" + str.tostring(previousPhase) + ",\"phase_to\":4}")
                else if z.phase == 0 and bar_index > z.bornBar
                    bool flipped = z.dir == 1 ? close < z.lo : close > z.hi
                    if flipped
                        z.phase := 1
                        z.flipBar := bar_index
                        z.flipTime := time_close
                        newInversion := true
                        f_event(z.dir == -1 ? "Bullish IFVG confirmed" : "Bearish IFVG confirmed", -z.dir)
                        f_auditRecord(z, "inverted", z.dir == 1 ? "Later close strictly below gap low" : "Later close strictly above gap high", "{\"phase_from\":0,\"phase_to\":1}")
                else if z.phase == 1
                    bool invalid = z.dir == -1 ? close < z.lo : close > z.hi
                    if invalid
                        z.phase := 3
                        z.endTime := time_close
                        newInvalidation := true
                        f_auditRecord(z, "invalidated", z.dir == -1 ? "Close strictly below bullish IFVG low" : "Close strictly above bearish IFVG high", "{\"phase_from\":1,\"phase_to\":3}")

    bool middleBodyOk = birthBodyAtr == 0 or math.abs(close[1] - open[1]) >= atrValue[1] * birthBodyAtr
    bool bullGap = low > high[2] and (minGapAtr == 0 or low - high[2] >= atrValue * minGapAtr) and middleBodyOk
    bool bearGap = high < low[2] and (minGapAtr == 0 or low[2] - high >= atrValue * minGapAtr) and middleBodyOk
    if bullGap or bearGap
        int dir = bullGap ? 1 : -1
        array.push(zones, Zone.new(time, dir, 0, bullGap ? low : low[2], bullGap ? high[2] : high, bar_index, time, na, na, na))
        newGap := true
        f_auditRecord(array.last(zones), "gap_detected", bullGap ? "Low strictly above high two bars earlier; birth filters passed" : "High strictly below low two bars earlier; birth filters passed", "{\"phase_to\":0}")
        if array.size(zones) > zoneCap
            f_auditRecord(array.first(zones), "evicted", "Removed from retained zones at capacity " + str.tostring(zoneCap) + "; an existing paper plan continues", "{\"zone_cap\":" + str.tostring(zoneCap) + "}")
            array.shift(zones)

    // Newest qualifying inversion wins; ties use newest birth. No opposing entries.
    int chosen = -1
    int chosenFlip = -1
    int chosenBirth = -1
    int latestCandidateFlip = -1
    string blockedReason = ""
    float chosenStop = na
    float chosenTarget = na
    float chosenRisk = na
    float chosenQty = na
    if not paperActive and not dayLocked and not paperClosed and array.size(zones) > 0
        for i = 0 to array.size(zones) - 1
            Zone z = array.get(zones, i)
            if z.phase == 1
                int direction = -z.dir
                bool overlaps = low <= z.hi and high >= z.lo
                bool rejects = direction == 1 ? close > z.hi and close > open : close < z.lo and close < open
                bool trigger = entryMode == "Inversion close" ? z.flipBar == bar_index : bar_index > z.flipBar and overlaps and rejects
                if trigger
                    [allowed, reason, cx, sw, sm, bi, dp] = f_gates(direction)
                    [plannedStop, plannedTarget, distance, qty, riskOk] = f_riskPlan(direction)
                    if not riskOk
                        reason := "Plan rejected: stop / size / tick risk"
                    if z.flipBar >= latestCandidateFlip
                        blockedReason := reason
                        latestCandidateFlip := z.flipBar
                    if allowed and riskOk and (z.flipBar > chosenFlip or (z.flipBar == chosenFlip and z.bornBar > chosenBirth))
                        chosen := i
                        chosenFlip := z.flipBar
                        chosenBirth := z.bornBar
                        chosenStop := plannedStop
                        chosenTarget := plannedTarget
                        chosenRisk := distance
                        chosenQty := qty
    // Observe every eligible trigger, including locks and losing candidates, before
    // the winner opens its plan. The original selection above remains authoritative.
    if auditEnabled and array.size(zones) > 0
        for i = 0 to array.size(zones) - 1
            Zone z = array.get(zones, i)
            if z.phase == 1
                int direction = -z.dir
                bool overlaps = low <= z.hi and high >= z.lo
                bool rejects = direction == 1 ? close > z.hi and close > open : close < z.lo and close < open
                bool retestTouch = entryMode == "Retest rejection" and bar_index > z.flipBar and overlaps
                bool trigger = entryMode == "Inversion close" ? z.flipBar == bar_index : retestTouch and rejects
                if trigger or retestTouch
                    string winnerId = chosen >= 0 ? f_setupId(array.get(zones, chosen)) : ""
                    string locks = (paperActive ? "Paper plan already active; " : "") + (dayLocked ? "Daily resolved-trade cap reached; " : "") + (paperClosed ? "Exit bar cannot re-enter; " : "")
                    string blockers = locks + f_auditGateReason(direction)
                    bool accepted = i == chosen
                    string why = not trigger ? "Overlap without directional rejection close" : accepted ? "Trigger, required gates and risk passed; selected by newest inversion then birth" : blockers != "" ? blockers : "Another qualifying setup won by newest inversion then birth"
                    string detail = "{\"trigger_passed\":" + f_jsonBool(trigger) + ",\"paper_active\":" + f_jsonBool(paperActive) + ",\"day_locked\":" + f_jsonBool(dayLocked) + ",\"paper_closed_this_bar\":" + f_jsonBool(paperClosed) + ",\"day_resolved\":" + str.tostring(dayResolved) + ",\"winner_setup_id\":" + (winnerId == "" ? "null" : f_jsonString(winnerId)) + "}"
                    f_auditRecord(z, not trigger ? "retest_rejected" : accepted ? "entry_accepted" : "entry_skipped", why, detail)
    if not paperActive and not dayLocked and not paperClosed and array.size(zones) > 0
        if chosen >= 0
            Zone z = array.get(zones, chosen)
            paperDir := -z.dir
            paperEntry := close
            paperStop := chosenStop
            paperRisk := chosenRisk
            paperTarget := chosenTarget
            paperQty := chosenQty
            paperEntryBar := bar_index
            paperEntryTime := time_close
            paperActive := true
            z.phase := 2
            z.endTime := time_close
            paperSetup := z.copy()
            signalDir := paperDir
            signalZoneId := z.id
            signalEntry := paperEntry
            signalStop := paperStop
            signalTarget := paperTarget
            signalQty := paperQty
            newLong := paperDir == 1
            newShort := paperDir == -1
            f_event((paperDir == 1 ? "LONG" : "SHORT") + " · " + str.tostring(paperEntry, format.mintick), paperDir)

    analysisDir := paperActive ? paperDir : 0
    int latestFlip = -1
    if not paperActive and array.size(zones) > 0
        for i = 0 to array.size(zones) - 1
            Zone z = array.get(zones, i)
            if z.phase == 1 and z.flipBar >= latestFlip
                analysisDir := -z.dir
                latestFlip := z.flipBar
        if analysisDir == 0
            int i = array.size(zones) - 1
            while i >= 0 and analysisDir == 0
                Zone z = array.get(zones, i)
                if z.phase == 0
                    analysisDir := -z.dir
                i -= 1
    [analysisAllowed, analysisReason, cx, sw, sm, bi, dp] = f_gates(analysisDir)
    factorContext := cx
    factorSweep := sw
    factorSMT := sm
    factorBias := bi
    factorDisplacement := dp
    factorSession := inSession
    smtAvailable := smtAvailableNow
    deskReason := paperActive ? "Manage fixed stop / target · paper" : dayLocked ? "Daily resolved-trade cap reached" : blockedReason != "" ? blockedReason : latestFlip >= 0 ? (entryMode == "Retest rejection" ? "Wait for a subsequent rejection" : "Wait for a new inversion close") : analysisDir != 0 ? "Wait for a close through the gap" : "Wait for a confirmed three-candle gap"

// Display summaries reflect closed state; strategy and indicator share this source.
int freshCount = 0
int armedCount = 0
int terminalCount = 0
if array.size(zones) > 0
    for i = 0 to array.size(zones) - 1
        Zone z = array.get(zones, i)
        freshCount += z.phase == 0 ? 1 : 0
        armedCount += z.phase == 1 ? 1 : 0
        terminalCount += z.phase >= 2 ? 1 : 0
string deskState = paperActive ? (paperDir == 1 ? "LONG" : "SHORT") : dayLocked ? "DAY DONE" : armedCount > 0 ? "ARMED" : "WAIT"
int factorCount = (factorContext ? 1 : 0) + (factorSweep ? 1 : 0) + (factorSMT ? 1 : 0) + (factorBias ? 1 : 0) + (factorDisplacement ? 1 : 0) + (factorSession ? 1 : 0)
int gateCount = (htfGate ? 1 : 0) + (sweepGate ? 1 : 0) + (smtGate ? 1 : 0) + (biasGate ? 1 : 0) + (displacementGate ? 1 : 0) + (sessionGate ? 1 : 0)
int gatePassed = (htfGate and factorContext ? 1 : 0) + (sweepGate and factorSweep ? 1 : 0) + (smtGate and factorSMT ? 1 : 0) + (biasGate and factorBias ? 1 : 0) + (displacementGate and factorDisplacement ? 1 : 0) + (sessionGate and factorSession ? 1 : 0)
[laneSideA, laneHiA, laneLoA, laneDistA, laneStateA] = f_laneSummary(laneA, laneOnA)
[laneSideB, laneHiB, laneLoB, laneDistB, laneStateB] = f_laneSummary(laneB, laneOnB)
[laneSideC, laneHiC, laneLoC, laneDistC, laneStateC] = f_laneSummary(laneC, laneOnC)

// A bounded report is emitted once after history is rebuilt. Realtime additions
// are emitted by f_auditRecord, never by repeatedly dumping the same snapshots.
if auditEnabled and auditLogExport and barstate.islastconfirmedhistory
    int matching = 0
    if array.size(auditTrail) > 0
        for event in auditTrail
            if auditSetupFilter == "" or auditSetupFilter == event.setupId
                matching += 1
    string report = "{\"schema\":\"ifvg-desk.report.v1\",\"version\":" + f_jsonString(AUDIT_VERSION) + ",\"symbol\":" + f_jsonString(syminfo.tickerid) + ",\"timeframe\":" + f_jsonString(timeframe.period) + ",\"script_kind\":" + f_jsonString(IS_STRATEGY ? "strategy" : "indicator") + ",\"dataset_first_open_ms\":" + str.tostring(auditDatasetStart) + ",\"through_close_ms\":" + str.tostring(time_close) + ",\"feed_session\":" + f_jsonString(syminfo.session) + ",\"feed_timezone\":" + f_jsonString(syminfo.timezone) + ",\"mintick\":" + f_jsonEntry(syminfo.mintick) + ",\"point_value\":" + f_jsonEntry(syminfo.pointvalue) + ",\"currency\":" + f_jsonString(syminfo.currency)
    report += ",\"setup_filter\":" + f_jsonString(auditSetupFilter) + ",\"retained_events\":" + str.tostring(array.size(auditTrail)) + ",\"matching_events\":" + str.tostring(matching) + ",\"dropped_events\":" + str.tostring(array.get(auditDropped, 0)) + ",\"oldest_retained_close_ms\":" + (array.size(auditTrail) > 0 ? str.tostring(array.first(auditTrail).stamp) : "null") + ",\"history_is_bounded\":true,\"recalculation_rebuilds_history\":true,\"broker_properties\":\"Attach Strategy Properties separately; audit is the paper engine\",\"settings\":" + auditSettings + "}"
    log.info("{0}", report)
    if array.size(auditTrail) > 0
        for event in auditTrail
            if auditSetupFilter == "" or auditSetupFilter == event.setupId
                log.info("{0}", event.payload)

// Plain alert conditions plus one complete close-confirmed JSON entry event.
alertcondition(newLong, "IFVG Desk · Long", "Confirmed IFVG long paper plan on {{ticker}} {{interval}} · close {{close}}")
alertcondition(newShort, "IFVG Desk · Short", "Confirmed IFVG short paper plan on {{ticker}} {{interval}} · close {{close}}")
alertcondition(newInversion, "IFVG Desk · Inversion", "A gap inverted at closed bar on {{ticker}} {{interval}}. Gates / risk may still block entry.")
alertcondition(newGap, "IFVG Desk · New gap", "A three-candle FVG formed on the confirmed close of {{ticker}} {{interval}}. This is context, not an entry.")
alertcondition(newInvalidation, "IFVG Desk · IFVG invalidated", "An armed IFVG closed through its opposite edge on {{ticker}} {{interval}} and is no longer eligible.")
alertcondition(newExpiry, "IFVG Desk · Zone expired", "A retained active chart zone exceeded its configured age on {{ticker}} {{interval}} and is no longer eligible.")
alertcondition(paperClosed, "IFVG Desk · Paper resolved", "IFVG Desk paper trade resolved on {{ticker}} {{interval}}. Paper record excludes costs.")
if barstate.isconfirmed and sendJsonAlerts and signalDir != 0
    string eventId = syminfo.tickerid + "|" + timeframe.period + "|" + str.tostring(time_close) + "|" + str.tostring(signalZoneId)
    string payload = "{\"schema\":\"ifvg-desk.v1\",\"event\":\"paper_entry\",\"id\":" + f_jsonString(eventId) + ",\"symbol\":" + f_jsonString(syminfo.tickerid) + ",\"timeframe\":" + f_jsonString(timeframe.period) + ",\"bar_close_ms\":" + str.tostring(time_close) + ",\"zone_id\":" + str.tostring(signalZoneId) + ",\"direction\":" + str.tostring(signalDir) + ",\"entry\":" + f_jsonEntry(signalEntry) + ",\"stop\":" + f_jsonPrice(signalStop) + ",\"target\":" + f_jsonPrice(signalTarget) + ",\"quantity_estimate\":" + f_jsonNumber(signalQty) + ",\"symbol_currency\":" + f_jsonString(syminfo.currency)
    payload += ",\"setup_id\":" + f_jsonString(f_setupId(paperSetup))
    payload += ",\"factors\":{\"context\":" + f_jsonBool(factorContext) + ",\"sweep\":" + f_jsonBool(factorSweep) + ",\"smt\":" + f_jsonBool(factorSMT) + ",\"smt_available\":" + f_jsonBool(smtAvailable) + ",\"bias\":" + f_jsonBool(factorBias) + ",\"displacement\":" + f_jsonBool(factorDisplacement) + ",\"session\":" + f_jsonBool(factorSession) + "}"
    payload += ",\"gates\":{\"context\":" + f_jsonBool(htfGate) + ",\"sweep\":" + f_jsonBool(sweepGate) + ",\"smt\":" + f_jsonBool(smtGate) + ",\"bias\":" + f_jsonBool(biasGate) + ",\"displacement\":" + f_jsonBool(displacementGate) + ",\"session\":" + f_jsonBool(sessionGate) + "},\"confirmed\":true,\"costs_included\":false}"
    alert(payload, alert.freq_once_per_bar_close)

// Broker companion: shared paper-paced signals, independent actual broker exits.
// Costs / path assumptions / fill prices mean these are not the indicator's ledger.
var int brokerEntryBar = na
var float brokerStop = na
var float brokerTarget = na
var int brokerSkipped = 0
if barstate.isconfirmed
    if signalDir != 0
        if strategy.position_size == 0
            brokerEntryBar := bar_index
            brokerStop := signalStop
            brokerTarget := signalTarget
            if signalDir == 1
                strategy.entry("L", strategy.long, qty = signalQty)
                // Submitted on the entry calculation: never a one-bar naked entry.
                strategy.exit("XL", from_entry = "L", stop = brokerStop, limit = brokerTarget)
            else
                strategy.entry("S", strategy.short, qty = signalQty)
                strategy.exit("XS", from_entry = "S", stop = brokerStop, limit = brokerTarget)
        else
            brokerSkipped += 1
    if strategy.position_size != 0
        bool brokerTimeout = not na(brokerEntryBar) and bar_index - brokerEntryBar >= maxHoldBars
        bool brokerSessionClose = closeAtSessionEnd and not inSession
        if brokerTimeout or brokerSessionClose
            strategy.close(strategy.position_size > 0 ? "L" : "S", comment = brokerSessionClose ? "Session close" : "Timeout", immediately = true)
        else if strategy.position_size > 0
            strategy.exit("XL", from_entry = "L", stop = brokerStop, limit = brokerTarget)
        else
            strategy.exit("XS", from_entry = "S", stop = brokerStop, limit = brokerTarget)

// ============================================================================
// IFVG DESK / DISPLAY ONLY — Dashboard + Chart Studio + Narrative + Audit (v1.2)
// Reads the shared engine. No renderer setting participates in an entry decision.
// v1.1: the panel answers the trader's questions (what · why not · what next),
// lanes show alignment with the side being analysed, checks read required /
// optional, the plan shows distances, the chart labels only what matters.
// v1.2: optional paged history reads frozen engine events by stable Setup ID.
// ============================================================================
string vGroup = "Display · your desk"
string vTheme = input.string("Aurora", "Theme", options = ["Aurora", "Royal Gold", "Colorblind safe"], group = vGroup, display = display.none)
string vView = input.string(IS_STRATEGY ? "Minimal" : "Full", "Chart view", options = ["Full", "Focus", "Minimal"], group = vGroup, tooltip = "Full: gap lifecycle, higher-frame zones, session range and reference levels. Focus: entry-chart zones and plans. Minimal: signals and active plan. Display only.", display = display.none)
bool vShowDash = input.bool(not IS_STRATEGY, "Show dashboard", group = vGroup, display = display.none)
string vDashMode = input.string("Compact", "Dashboard rows", options = ["Compact", "Full"], group = vGroup, tooltip = "Compact: state, reason, ribbon, context lanes, checks, plan, today. Full adds the paper record and the event tape.", display = display.none)
string vDashPos = input.string("Top Right", "Dashboard position", options = ["Top Right", "Top Left", "Bottom Right", "Bottom Left", "Middle Right", "Middle Left"], group = vGroup, display = display.none)
string vSize = input.string("Medium", "Dashboard size", options = ["Small", "Medium", "Large"], group = vGroup, display = display.none)
int vZoneLimit = input.int(10, "Visible entry-chart zones", minval = 2, maxval = 24, group = vGroup, display = display.none)
int vOldZones = input.int(3, "Retired zones visible", minval = 0, maxval = 8, group = vGroup, display = display.none)
float vRadius = input.float(8.0, "Zone visibility radius · ATR", minval = 1.0, maxval = 50.0, step = 0.5, group = vGroup, tooltip = "Distance filter affects drawings only. All retained zones still feed the engine.", display = display.none)
int vPlanHistory = input.int(3, "Earlier plans visible", minval = 0, maxval = 6, group = vGroup, display = display.none)
bool vContext = input.bool(true, "Project the three context gaps", group = vGroup, display = display.none)
bool vReference = input.bool(true, "09:30 open and previous-day high / low", group = vGroup, display = display.none)
bool vSession = input.bool(true, "New York session range", group = vGroup, display = display.none)
bool vMarks = input.bool(true, "Inversion marks and signal tickets", group = vGroup, display = display.none)
bool vTape = input.bool(true, "Recent event tape (Full rows)", group = vGroup, display = display.none)
string vNarrGroup = "Co-Pilot · plain-language read"
string vNarr = input.string(IS_STRATEGY ? "Off" : "Standard", "Narrative depth", options = ["Off", "Brief", "Standard", "Detailed"], group = vNarrGroup, display = display.none)
string vNarrPos = input.string("Bottom Right", "Narrative position", options = ["Top Right", "Top Left", "Bottom Right", "Bottom Left", "Middle Right", "Middle Left"], group = vNarrGroup, tooltip = "If it shares the dashboard corner, the narrative automatically moves to the opposite lower corner.", display = display.none)
string vNarrSize = input.string("Medium", "Narrative size", options = ["Small", "Medium", "Large"], group = vNarrGroup, display = display.none)
bool vWhyOn = input.bool(true, "Show WHY", group = vNarrGroup, display = display.none)
bool vPlanOn = input.bool(true, "Show PLAN", group = vNarrGroup, display = display.none)
bool vLimitsOn = input.bool(true, "Show data limits", group = vNarrGroup, display = display.none)
string vAuditGroup = "Audit · setup history"
bool vAuditShow = input.bool(false, "Show setup history", group = vAuditGroup, tooltip = "Browse the frozen reasons recorded on each decision bar. Independent of the dashboard and narrative. Hover an event for its complete setup ID and saved diagnostic record.", display = display.none)
string vAuditPos = input.string("Bottom Left", "Setup history position", options = ["Top Right", "Top Left", "Bottom Right", "Bottom Left", "Middle Right", "Middle Left"], group = vAuditGroup, tooltip = "Moves to an unused position if the dashboard or narrative already occupies this position.", display = display.none)
int vAuditPage = input.int(1, "Setup history page · newest first", minval = 1, maxval = 63, group = vAuditGroup, tooltip = "Eight events per page. Page 1 shows the newest matching records. Pages beyond the retained history are clamped to the last available page. Use the full Setup ID filter in the audit settings to follow one setup.", display = display.none)

color vBg = vTheme == "Royal Gold" ? #151411 : #101724
color vBand = vTheme == "Royal Gold" ? #25221B : #1A2637
color vStripe = vTheme == "Royal Gold" ? #1C1A16 : #141E2D
color vBorder = vTheme == "Royal Gold" ? #574D34 : #34465F
color vAccent = vTheme == "Royal Gold" ? #DDC68B : #AABFE4
color vUp = vTheme == "Colorblind safe" ? #65B8FF : vTheme == "Royal Gold" ? #83D7BA : #53DAC6
color vDown = vTheme == "Colorblind safe" ? #FFC169 : #FFB454
color vText = #E4EBF5
color vMuted = #A2AFC0
color vDim = #697B91
string vBodySize = vSize == "Large" ? size.normal : vSize == "Medium" ? size.small : size.tiny
string vHeroSize = vSize == "Large" ? size.large : size.normal
string vNarrBody = vNarrSize == "Large" ? size.normal : vNarrSize == "Medium" ? size.small : size.tiny
float vAtrRaw = ta.atr(14)
float vAtr = barstate.isconfirmed ? vAtrRaw : vAtrRaw[1]
float vNarrHigh = ta.highest(high, 100)
float vNarrLow = ta.lowest(low, 100)
float vPrice = barstate.isconfirmed ? close : close[1]
int vClock = barstate.isconfirmed ? time_close : time_close[1]
int vBarMs = int(timeframe.in_seconds() * 1000)

vPos(string p) =>
    switch p
        "Top Left" => position.top_left
        "Bottom Left" => position.bottom_left
        "Bottom Right" => position.bottom_right
        "Middle Left" => position.middle_left
        "Middle Right" => position.middle_right
        => position.top_right

vPx(float p) => na(p) ? "—" : str.tostring(p, format.mintick)
vNum(float p) => na(p) ? "—" : str.tostring(p, "0.0")
vSide(int d) => d == 1 ? "LONG" : d == -1 ? "SHORT" : "—"
vSideLower(int d) => d == 1 ? "long" : d == -1 ? "short" : "—"
vZone(int d) => d == 1 ? "bull" : d == -1 ? "bear" : "—"
vZoneWord(int d) => d == 1 ? "bullish" : d == -1 ? "bearish" : "—"
vColor(int d) => d == 1 ? vUp : d == -1 ? vDown : vMuted
vTf(string tf) => str.contains(tf, "D") or str.contains(tf, "W") or str.contains(tf, "M") or str.contains(tf, "S") ? tf : tf + "m"
vJoin(string acc, string item) => acc == "" ? item : acc + " · " + item
vClip(string txt, int limit) => str.length(txt) > limit ? str.substring(txt, 0, limit - 1) + "…" : txt
vAuditShortId(string id) => str.length(id) > 16 ? "…" + str.substring(id, str.length(id) - 16) : id
// Display transformations use saved snapshot fields, never today's confluence.
vAuditTip(AuditEvent e) =>
    "Setup ID: " + e.setupId + "\n" + str.format_time(e.stamp, "yyyy-MM-dd HH:mm:ss", NY_TZ) + " New York\n" + e.kind + " · " + vSide(e.dir) + " · " + e.reason + "\n" + e.summary + "\n\n" + e.payload
vAuditFreePos(string requested, string narrativePos, bool narrativeOn) =>
    string out = requested
    bool occupied = (vShowDash and requested == vDashPos) or (narrativeOn and requested == narrativePos)
    if occupied
        array<string> choices = array.from("Bottom Left", "Top Left", "Middle Left", "Middle Right", "Bottom Right", "Top Right")
        for i = 0 to array.size(choices) - 1
            string candidate = array.get(choices, i)
            if (not vShowDash or candidate != vDashPos) and (not narrativeOn or candidate != narrativePos)
                out := candidate
                break
    out
// "0930-1100" → "09:30–11:00 NY"; the session input may carry a days suffix.
vSessionText() =>
    string s = tradeSession
    str.length(s) >= 9 ? str.substring(s, 0, 2) + ":" + str.substring(s, 2, 4) + "–" + str.substring(s, 5, 7) + ":" + str.substring(s, 7, 9) + " NY" : s + " NY"
vSymbolShort(string sym) =>
    int at = str.pos(sym, ":")
    na(at) ? sym : str.substring(sym, at + 1)
// Mirrors f_gates' order: the first required check that fails right now.
vGateReason() =>
    sessionGate and not factorSession ? "outside " + vSessionText() : htfGate and not factorContext ? "no aligned HTF gap overlap" : sweepGate and not factorSweep ? "no recent sweep" : smtGate and not factorSMT ? (smtAvailable ? "SMT not confirming" : "SMT has no data") : biasGate and not factorBias ? "prior-day bias disagrees" : displacementGate and not factorDisplacement ? "no displacement on the bar" : ""
// The latest inverted zone still eligible (phase 1): what ARMED refers to.
vArmedZone() =>
    float hi = na
    float lo = na
    int dir = 0
    int flipBar = -1
    if array.size(zones) > 0
        for i = 0 to array.size(zones) - 1
            Zone z = array.get(zones, i)
            if z.phase == 1 and z.flipBar >= flipBar
                hi := z.hi
                lo := z.lo
                dir := -z.dir
                flipBar := z.flipBar
    [hi, lo, dir, flipBar]
vCell(table t, int c, int r, string txt, color fg, color bg, string sz, string align, string tip = "") =>
    table.cell(t, c, r, txt, text_color = fg, bgcolor = bg, text_size = sz, text_halign = align, text_font_family = font.family_monospace, tooltip = tip)
vMerged(table t, int r, string txt, color fg, color bg, string sz, string align = text.align_left, string tip = "") =>
    table.merge_cells(t, 0, r, 3, r)
    vCell(t, 0, r, txt, fg, bg, sz, align, tip)
vBandRow(table t, int r, string txt) =>
    table.merge_cells(t, 0, r, 3, r)
    vCell(t, 0, r, txt, vAccent, vBand, vBodySize, text.align_left)
vWhere(int side, float dist, float hi, float lo) =>
    side == 0 ? "—" : dist == 0 ? "inside" : (lo > vPrice ? "↑ " : "↓ ") + vNum(dist / math.max(vAtr, syminfo.mintick)) + " ATR"
vLane(table t, int r, string tf, int side, float dist, float hi, float lo) =>
    color bg = r % 2 == 0 ? vStripe : vBg
    bool aligned = analysisDir != 0 and side == analysisDir
    string tip = "Nearest retained confirmed " + vTf(tf) + " gap: " + vPx(lo) + " to " + vPx(hi) + ". Aligned = the gap points the way the " + vSideLower(analysisDir) + " would trade. The HTF check searches ALL aligned gaps in enabled lanes, not only this nearest one."
    vCell(t, 0, r, vTf(tf), vText, bg, vBodySize, text.align_right, tip)
    vCell(t, 1, r, side == 0 ? "—" : analysisDir == 0 ? "·" : aligned ? "✓" : "✗", side == 0 ? vDim : analysisDir == 0 ? vDim : aligned ? vUp : vDown, bg, vBodySize, text.align_center, tip)
    vCell(t, 2, r, side == 0 ? "no gap in range" : vZone(side) + " " + vPx(lo) + "–" + vPx(hi), vColor(side), bg, vBodySize, text.align_left, tip)
    vCell(t, 3, r, vWhere(side, dist, hi, lo), vMuted, bg, vBodySize, text.align_right, "Where the gap sits versus the last confirmed close, in chart ATR. Inside = the close is within the gap.")
vCheck(table t, int r, string name, bool pass, bool gate, bool available, string info) =>
    color bg = r % 2 == 0 ? vStripe : vBg
    table.merge_cells(t, 0, r, 1, r)
    vCell(t, 0, r, name, gate ? vText : vMuted, bg, vBodySize, text.align_left, info)
    vCell(t, 2, r, available ? pass ? "✓ met" : "✗ not met" : "— no data", available and pass ? vUp : available ? gate ? vDown : vMuted : vMuted, bg, vBodySize, text.align_left, info)
    vCell(t, 3, r, gate ? "● required" : "○ optional", gate ? vAccent : vDim, bg, vBodySize, text.align_right, "Required checks must be met on the entry bar. Optional checks are shown for context and never block an entry. Switch any check in 03 · Confluence gates.")
vPlanRow(table t, int r, string name, string value, color fg, string tip = "") =>
    color bg = r % 2 == 0 ? vStripe : vBg
    table.merge_cells(t, 1, r, 3, r)
    vCell(t, 0, r, name, vMuted, bg, vBodySize, text.align_right, tip)
    vCell(t, 1, r, value, fg, bg, vBodySize, text.align_left, tip)
vMark(bool pass, bool available) => available ? pass ? "✓" : "✗" : "—"

// Bounded pools are rebuilt only on the last bar from engine-owned state.
var array<box> vBoxes = array.new<box>()
var array<line> vLines = array.new<line>()
var array<label> vLabels = array.new<label>()
var array<float> vUsedY = array.new<float>()
vLine(int x1, float y, int x2, color col, string style, int width = 1) =>
    array.push(vLines, line.new(x1, y, x2, y, xloc = xloc.bar_time, color = col, style = style, width = width))
vBox(int x1, float hi, int x2, float lo, color col, int opacity, string txt = "", string borderStyle = line.style_solid) =>
    array.push(vBoxes, box.new(x1, hi, x2, lo, xloc = xloc.bar_time, border_color = color.new(col, math.min(85, opacity - 15)), bgcolor = color.new(col, opacity), border_style = borderStyle, text = txt, text_color = color.new(col, 8), text_size = size.tiny, text_halign = text.align_left, text_valign = text.align_top))
// leftward = the text extends to the left of x (used for flip marks, so they never sit on a zone's own label).
vTag(int x, float y, string txt, color col, string tip, bool nudge = false, bool leftward = false) =>
    float labelY = y
    if nudge
        float gap = math.max(vAtr * 0.6, syminfo.mintick * 8)
        if array.size(vUsedY) > 0
            // Moving upward is safe in one pass only when lower levels come first.
            array.sort(vUsedY, order.ascending)
            for j = 0 to array.size(vUsedY) - 1
                float occupied = array.get(vUsedY, j)
                if math.abs(labelY - occupied) < gap
                    labelY := occupied + gap
        array.push(vUsedY, labelY)
        if labelY != y
            array.push(vLines, line.new(x, y, x, labelY, xloc = xloc.bar_time, color = color.new(col, 65), style = line.style_dotted))
    array.push(vLabels, label.new(x, labelY, txt, xloc = xloc.bar_time, color = color.new(vBg, 5), style = leftward ? label.style_label_right : label.style_label_left, textcolor = col, size = size.tiny, tooltip = tip))

type VTicket
    int stamp
    int stopTime
    int dir
    float entry
    float stop
    float target
    float exitPrice
    string result
var array<VTicket> vTickets = array.new<VTicket>()
if barstate.isconfirmed
    if paperClosed and array.size(vTickets) > 0
        VTicket last = array.last(vTickets)
        last.stopTime := time_close
        last.exitPrice := paperExitPrice
        last.result := paperExitReason + " " + vNum(paperLastR) + "R"
    if newLong or newShort
        array.push(vTickets, VTicket.new(time_close, na, signalDir, signalEntry, signalStop, signalTarget, na, ""))
        if array.size(vTickets) > 7
            array.shift(vTickets)
// The most recent completed plan, as one short phrase for the panel and the Co-Pilot.
vLastTicket() =>
    string out = ""
    if array.size(vTickets) > 0
        for offset = 0 to array.size(vTickets) - 1
            VTicket t = array.get(vTickets, array.size(vTickets) - 1 - offset)
            if out == "" and not na(t.stopTime)
                // Earlier days carry their date so "last" is never mistaken for today.
                bool sameDay = dayofmonth(t.stopTime, NY_TZ) == dayofmonth(vClock, NY_TZ) and month(t.stopTime, NY_TZ) == month(vClock, NY_TZ)
                out := str.format_time(t.stopTime, sameDay ? "HH:mm" : "MMM d HH:mm", NY_TZ) + " " + vSideLower(t.dir) + " · " + t.result
    out

// A session silhouette keeps useful context on screen between entries.
var int vSessLeft = na
var int vSessRight = na
if barstate.isconfirmed
    if sessionStart
        vSessLeft := time
    if inSession
        vSessRight := time_close

bool vFull = vView == "Full"
bool vMinimal = vView == "Minimal"
int vVisibleZones = 0
if barstate.islast or barstate.islastconfirmedhistory
    while array.size(vBoxes) > 0
        box.delete(array.pop(vBoxes))
    while array.size(vLines) > 0
        line.delete(array.pop(vLines))
    while array.size(vLabels) > 0
        label.delete(array.pop(vLabels))
    array.clear(vUsedY)
    int rightTime = time_close + 12 * vBarMs
    int tagTime = time_close + 2 * vBarMs
    int contextLeft = time - 30 * vBarMs
    [armedHi, armedLo, armedDir, armedFlip] = vArmedZone()
    if vFull and vSession and not na(vSessLeft) and not na(sessionHi) and not na(sessionLo)
        vBox(vSessLeft, sessionHi, nz(vSessRight, time_close), sessionLo, vAccent, 95, "NEW YORK / SESSION RANGE", line.style_dotted)
    if vFull and vContext
        // Context boxes carry no in-box text (titles overprinted when lanes shared an edge); the lane tag says it.
        // Lanes whose upper edges sit within one label gap and share a side get ONE merged tag ("15m · 60m bear ✓").
        array<string> lnTf = array.new<string>()
        array<int> lnSide = array.new<int>()
        array<float> lnHi = array.new<float>()
        if laneSideA != 0 and laneDistA <= vRadius * vAtr
            vBox(contextLeft, laneHiA, rightTime, laneLoA, vColor(laneSideA), 95, "", line.style_dotted)
            array.push(lnTf, vTf(tfA))
            array.push(lnSide, laneSideA)
            array.push(lnHi, laneHiA)
        if laneSideB != 0 and laneDistB <= vRadius * vAtr
            vBox(contextLeft, laneHiB, rightTime, laneLoB, vColor(laneSideB), 94, "", line.style_dotted)
            array.push(lnTf, vTf(tfB))
            array.push(lnSide, laneSideB)
            array.push(lnHi, laneHiB)
        if laneSideC != 0 and laneDistC <= vRadius * vAtr
            vBox(contextLeft, laneHiC, rightTime, laneLoC, vColor(laneSideC), 93, "", line.style_dotted)
            array.push(lnTf, vTf(tfC))
            array.push(lnSide, laneSideC)
            array.push(lnHi, laneHiC)
        int laneCount = array.size(lnTf)
        if laneCount > 0
            float tagGap = math.max(vAtr * 0.6, syminfo.mintick * 8)
            array<bool> laneUsed = array.new<bool>(laneCount, false)
            for k = 0 to laneCount - 1
                int pick = -1
                for i = 0 to laneCount - 1
                    if not array.get(laneUsed, i) and (pick < 0 or array.get(lnHi, i) < array.get(lnHi, pick))
                        pick := i
                if pick >= 0
                    array.set(laneUsed, pick, true)
                    string names = array.get(lnTf, pick)
                    int side = array.get(lnSide, pick)
                    float y = array.get(lnHi, pick)
                    for i = 0 to laneCount - 1
                        if not array.get(laneUsed, i) and array.get(lnSide, i) == side and math.abs(array.get(lnHi, i) - y) <= tagGap
                            array.set(laneUsed, i, true)
                            names += " · " + array.get(lnTf, i)
                    vTag(rightTime, y, names + " " + vZone(side) + (analysisDir == 0 ? "" : side == analysisDir ? " ✓" : " ✗"), vColor(side), "Nearest confirmed higher-frame gap(s) on these lanes, projected for current context. ✓ = aligned with the side being analysed.", true)
    int retiredShown = 0
    if not vMinimal and array.size(zones) > 0
        for offset = 0 to array.size(zones) - 1
            int idx = array.size(zones) - 1 - offset
            Zone z = array.get(zones, idx)
            bool retired = z.phase >= 2
            float dist = math.max(0, math.max(z.lo - vPrice, vPrice - z.hi))
            bool eligible = vVisibleZones < vZoneLimit and dist <= vRadius * vAtr and (not retired or retiredShown < vOldZones)
            if eligible
                int until = retired ? nz(z.endTime, time_close) : rightTime
                bool flipped = not na(z.flipTime)
                bool armed = z.phase == 1
                color col = vColor(flipped ? -z.dir : z.dir)
                int alpha = retired ? 96 : flipped ? 82 : 94
                if flipped
                    vBox(z.bornTime, z.hi, z.flipTime, z.lo, vColor(z.dir), 96, "", line.style_dotted)
                // Only the latest eligible inversion carries text and a flip mark; every other zone reads by colour and border alone.
                bool latestArmed = armed and z.flipBar == armedFlip
                string zoneText = latestArmed ? "IFVG " + (-z.dir == 1 ? "↑" : "↓") + " · armed" : ""
                vBox(flipped ? z.flipTime : z.bornTime, z.hi, until, z.lo, col, alpha, zoneText, retired ? line.style_dotted : line.style_solid)
                vLine(flipped ? z.flipTime : z.bornTime, (z.hi + z.lo) * 0.5, until, color.new(col, retired ? 85 : 50), line.style_dotted)
                if vMarks and latestArmed
                    // The mark hangs to the left of the inversion close, over the pre-flip gap, clear of the zone's label.
                    vTag(z.flipTime, (z.hi + z.lo) * 0.5, -z.dir == 1 ? "↗ flip" : "↘ flip", col, "Confirmed inversion at " + str.format_time(z.flipTime, "MMM d HH:mm", NY_TZ) + " NY. Original gap " + vPx(z.lo) + "–" + vPx(z.hi) + ". The marker sits on the inversion close, never backdated.", false, true)
                vVisibleZones += 1
                if retired
                    retiredShown += 1
    if vFull and vReference
        if not na(nyOpen) and not na(nyOpenTime)
            vLine(nyOpenTime, nyOpen, rightTime, color.new(vAccent, 25), line.style_dashed)
            vTag(rightTime, nyOpen, "09:30  " + vPx(nyOpen), vAccent, "Open of the exact 09:30 New York bar. Absent when the chart has no bar opening at that time. Reference level, not a promised destination.", true)
        if not na(pdh) and math.abs(pdh - vPrice) <= vRadius * 2 * vAtr
            vLine(contextLeft, pdh, rightTime, color.new(vDown, 45), line.style_dotted)
            vTag(rightTime, pdh, "PDH  " + vPx(pdh), vDown, "Previous completed daily candle high (exchange/session daily bars).", true)
        if not na(pdl) and math.abs(pdl - vPrice) <= vRadius * 2 * vAtr
            vLine(contextLeft, pdl, rightTime, color.new(vUp, 45), line.style_dotted)
            vTag(rightTime, pdl, "PDL  " + vPx(pdl), vUp, "Previous completed daily candle low (exchange/session daily bars).", true)
    if array.size(vTickets) > 0
        int drawnHistory = 0
        for offset = 0 to array.size(vTickets) - 1
            int j = array.size(vTickets) - 1 - offset
            VTicket ticket = array.get(vTickets, j)
            bool active = false
            if na(ticket.stopTime)
                active := true
            if active or not vMinimal and drawnHistory < vPlanHistory
                int rt = active ? rightTime : ticket.stopTime
                color col = vColor(ticket.dir)
                int fade = active ? 0 : 58
                vBox(ticket.stamp, math.max(ticket.entry, ticket.target), rt, math.min(ticket.entry, ticket.target), col, active ? 90 : 97)
                vBox(ticket.stamp, math.max(ticket.entry, ticket.stop), rt, math.min(ticket.entry, ticket.stop), vMuted, active ? 95 : 98)
                vLine(ticket.stamp, ticket.entry, rt, color.new(col, fade), line.style_solid, active ? 2 : 1)
                vLine(ticket.stamp, ticket.stop, rt, color.new(vMuted, fade), line.style_dashed)
                vLine(ticket.stamp, ticket.target, rt, color.new(col, fade), line.style_dashed)
                if vMarks
                    vTag(ticket.stamp, ticket.entry, ticket.dir == 1 ? "▲ LONG" : "▼ SHORT", color.new(col, fade), "Paper entry " + vPx(ticket.entry) + " / stop " + vPx(ticket.stop) + " / target " + vPx(ticket.target) + ". Close-based reference, no costs.")
                if active
                    // Live plan tags sit two bars past the close so they stay inside a normal right margin.
                    vTag(tagTime, ticket.target, "TARGET  " + vPx(ticket.target), col, "Fixed-R paper reference. The strategy companion has separate broker fills and costs.", true)
                    vTag(tagTime, ticket.entry, "ENTRY   " + vPx(ticket.entry), col, "Accepted signal close.", true)
                    vTag(tagTime, ticket.stop, "STOP    " + vPx(ticket.stop), vMuted, "Swing invalidation with tick buffer.", true)
                else if not na(ticket.exitPrice)
                    vTag(rt, ticket.exitPrice, ticket.result, color.new(col, 20), "Resolved paper outcome. Same-bar stop/target collisions are counted stop-first; costs excluded.")
                if not active
                    drawnHistory += 1

// Global plot family: closed-bar pulses never appear intrabar.
plotshape(vMarks and newLong and barstate.isconfirmed, "Long IFVG close", shape.triangleup, location.belowbar, vUp, size = size.small)
plotshape(vMarks and newShort and barstate.isconfirmed, "Short IFVG close", shape.triangledown, location.abovebar, vDown, size = size.small)
bgcolor(vFull and vSession and inSession ? color.new(vAccent, 98) : na, title = "New York session tint")
plot(signalDir, "Accepted signal direction", display = display.data_window)
plot(paperNetR, "Paper cumulative R (no costs)", display = display.data_window)
plot(paperTrades, "Resolved paper trades", display = display.data_window)

var table vDash = table.new(vPos(vDashPos), 4, 40, bgcolor = vShowDash ? vBg : color.new(vBg, 100), frame_color = vShowDash ? vBorder : color.new(vBorder, 100), frame_width = vShowDash ? 1 : 0, border_width = 0)
string vEffectiveNarrPos = vShowDash and vNarrPos == vDashPos ? vDashPos == "Bottom Left" ? "Bottom Right" : "Bottom Left" : vNarrPos
bool vNarrTableOn = not IS_STRATEGY and vNarr != "Off"
var table vNarrTable = table.new(vPos(vEffectiveNarrPos), 2, 9, bgcolor = vNarrTableOn ? vBg : color.new(vBg, 100), frame_color = vNarrTableOn ? vBorder : color.new(vBorder, 100), frame_width = vNarrTableOn ? 1 : 0, border_width = 0)
string vEffectiveAuditPos = vAuditFreePos(vAuditPos, vEffectiveNarrPos, vNarrTableOn)
var table vAuditTable = vAuditShow ? table.new(vPos(vEffectiveAuditPos), 4, 13, bgcolor = vBg, frame_color = vBorder, frame_width = 1, border_width = 0) : na
var label vStrategyNarr = na

if barstate.islast or barstate.islastconfirmedhistory
    table.clear(vDash, 0, 0, 3, 39)
    table.clear(vNarrTable, 0, 0, 1, 8)
    if vAuditShow
        table.clear(vAuditTable, 0, 0, 3, 12)
        // Build page indexes newest first; never reorder or mutate the engine journal.
        array<int> matches = array.new<int>()
        int retained = array.size(auditTrail)
        if retained > 0
            for offset = 0 to retained - 1
                int idx = retained - 1 - offset
                AuditEvent e = array.get(auditTrail, idx)
                if auditSetupFilter == "" or e.setupId == auditSetupFilter
                    array.push(matches, idx)
        int matched = array.size(matches)
        int pages = math.max(1, int(math.ceil(matched / 8.0)))
        int page = math.min(vAuditPage, pages)
        int first = (page - 1) * 8
        int shown = math.min(8, matched - first)
        int dropped = array.get(auditDropped, 0)
        string filterText = auditSetupFilter == "" ? "All setups" : "Setup " + vAuditShortId(auditSetupFilter)
        string limitsTip = "History covers the loaded chart bars and this calculation only. At most " + str.tostring(auditCap) + " records are retained across all setups; older events may be missing from a filtered setup. Input or chart changes rebuild the record. Export diagnostic records from Pine Logs when enabled."
        vMerged(vAuditTable, 0, "SETUP HISTORY · New York time", vAccent, vBand, size.small, text.align_left, "Each row is a saved event-time snapshot. Hover any event to see its full ID, reason, context and diagnostic JSON.")
        vMerged(vAuditTable, 1, filterText, vMuted, vBg, size.tiny, text.align_left, auditSetupFilter == "" ? "All retained setup IDs. Enter an exact full Setup ID in audit settings to filter." : "Exact Setup ID filter: " + auditSetupFilter)
        vMerged(vAuditTable, 2, "Page " + str.tostring(page) + "/" + str.tostring(pages) + " · " + str.tostring(shown) + " of " + str.tostring(matched) + " matches · newest first", vMuted, vBg, size.tiny, text.align_left, "Change Setup history page to browse older records. The page is clamped to available matching events.")
        vCell(vAuditTable, 0, 3, "TIME", vDim, vStripe, size.tiny, text.align_right)
        vCell(vAuditTable, 1, 3, "EVENT / SIDE", vDim, vStripe, size.tiny, text.align_left)
        vCell(vAuditTable, 2, 3, "SAVED REASON", vDim, vStripe, size.tiny, text.align_left)
        vCell(vAuditTable, 3, 3, "SETUP ID", vDim, vStripe, size.tiny, text.align_left)
        if shown > 0
            for j = 0 to shown - 1
                AuditEvent e = array.get(auditTrail, array.get(matches, first + j))
                int row = 4 + j
                color bg = j % 2 == 0 ? vBg : vStripe
                string tip = vAuditTip(e)
                vCell(vAuditTable, 0, row, str.format_time(e.stamp, "MM-dd\nHH:mm", NY_TZ), vMuted, bg, size.tiny, text.align_right, tip)
                vCell(vAuditTable, 1, row, vClip(e.kind, 22) + "\n" + vSide(e.dir), vColor(e.dir), bg, size.tiny, text.align_left, tip)
                vCell(vAuditTable, 2, row, vClip(e.reason, 38), vText, bg, size.tiny, text.align_left, tip)
                vCell(vAuditTable, 3, row, vAuditShortId(e.setupId), vMuted, bg, size.tiny, text.align_left, tip)
        else
            vCell(vAuditTable, 1, 4, auditEnabled ? "No records" : "Recording off", vMuted, vBg, size.tiny, text.align_left)
            vCell(vAuditTable, 2, 4, auditSetupFilter == "" ? "No retained events" : "No exact Setup ID match", vMuted, vBg, size.tiny, text.align_left, limitsTip)
        string retentionText = "Retained " + str.tostring(retained) + "/" + str.tostring(auditCap) + (dropped > 0 ? " · " + str.tostring(dropped) + " older records removed" : " · loaded bars only")
        vMerged(vAuditTable, 12, retentionText, dropped > 0 ? vDown : vDim, vBg, size.tiny, text.align_left, limitsTip)
    [aHi, aLo, aDir, aFlip] = vArmedZone()
    string sess = vSessionText()
    string gateWhy = vGateReason()
    string lastTxt = vLastTicket()
    float actualPlanR = paperActive and paperRisk > 0 ? math.abs(paperTarget - paperEntry) / paperRisk : na
    bool allGatesMet = gatePassed == gateCount
    color heroCol = paperActive ? vColor(paperDir) : deskState == "ARMED" ? vColor(aDir != 0 ? aDir : analysisDir) : deskState == "DAY DONE" ? vAccent : vMuted
    string heroTxt = paperActive ? "● " + vSide(paperDir) + " · PLAN ACTIVE" : deskState == "ARMED" ? "● ARMED · " + vSide(aDir != 0 ? aDir : analysisDir) : deskState
    string reasonTxt = paperActive ? "entry " + vPx(paperEntry) + " · stop " + vPx(paperStop) + " · target " + vPx(paperTarget) + " · " + vNum(actualPlanR) + "R" : dayLocked ? "today's " + (maxTradesDay == 1 ? "plan is used" : str.tostring(maxTradesDay) + " plans are used") + " · entries resume on the next New York day" : aDir != 0 ? vZoneWord(aDir) + " IFVG " + vPx(aLo) + "–" + vPx(aHi) + " · " + (entryMode == "Inversion close" ? "its flip close was not entered · only a new inversion close can enter" : gateWhy != "" ? "waiting: " + gateWhy : "waiting for a retest") : freshCount > 0 ? str.tostring(freshCount) + (freshCount == 1 ? " fresh gap" : " fresh gaps") + " · waiting for a close through one" : "no gap yet · waiting for a three-candle gap"
    string sideTag = (paperActive ? paperDir : aDir != 0 ? aDir : analysisDir) == 1 ? " · for a long" : (paperActive ? paperDir : aDir != 0 ? aDir : analysisDir) == -1 ? " · for a short" : ""
    string smtName = "SMT vs " + vSymbolShort(smtSymbol)
    if vShowDash
        int r = 0
        table.merge_cells(vDash, 0, r, 2, r)
        vCell(vDash, 0, r, "I F V G  D E S K", vAccent, vBand, vBodySize, text.align_left)
        vCell(vDash, 3, r, "v" + AUDIT_VERSION, vMuted, vBand, vBodySize, text.align_right)
        r += 1
        vMerged(vDash, r, heroTxt, heroCol, vBg, vHeroSize, text.align_center, "WAIT: no eligible inverted gap. ARMED: an inverted gap exists — not an entry. PLAN ACTIVE: a paper plan is open. DAY DONE: today's cap of resolved plans is reached.")
        r += 1
        vMerged(vDash, r, reasonTxt, vMuted, vBg, vBodySize, text.align_center, "Why the desk is in this state right now, from the same rules the checks table shows.")
        r += 1
        // Ribbon: done · in progress (amber) · not yet.
        bool stepGap = freshCount > 0 or armedCount > 0 or paperActive
        bool stepFlip = armedCount > 0 or paperActive
        bool checksDone = paperActive or (armedCount > 0 and allGatesMet)
        bool checksPending = not paperActive and armedCount > 0 and not allGatesMet
        string checksTxt = paperActive ? "03 CHECKS ✓" : armedCount > 0 ? (gateCount == 0 ? "03 CHECKS —" : "03 CHECKS " + str.tostring(gatePassed) + "/" + str.tostring(gateCount)) : "03 CHECKS"
        vCell(vDash, 0, r, "01 GAP" + (stepGap ? " ✓" : ""), stepGap ? vBg : vMuted, stepGap ? color.new(heroCol, 20) : vBand, vBodySize, text.align_center, "A three-candle gap exists on the chart.")
        vCell(vDash, 1, r, "02 FLIP" + (stepFlip ? " ✓" : ""), stepFlip ? vBg : vMuted, stepFlip ? color.new(heroCol, 20) : vBand, vBodySize, text.align_center, "A later close inverted a gap.")
        vCell(vDash, 2, r, checksTxt, checksDone ? vBg : checksPending ? vBg : vMuted, checksDone ? color.new(heroCol, 20) : checksPending ? color.new(vAccent, 45) : vBand, vBodySize, text.align_center, "Required checks met over required checks enabled, evaluated on the current closed bar.")
        vCell(vDash, 3, r, "04 PLAN" + (paperActive ? " ▶" : ""), paperActive ? vBg : vMuted, paperActive ? color.new(heroCol, 20) : vBand, vBodySize, text.align_center, "A paper plan with entry, stop and target is open.")
        r += 1
        vBandRow(vDash, r, "CONTEXT" + sideTag + " · nearest gap per frame")
        r += 1
        vCell(vDash, 0, r, "FRAME", vDim, vBg, vBodySize, text.align_right)
        vCell(vDash, 1, r, "ALIGNED", vDim, vBg, vBodySize, text.align_center)
        vCell(vDash, 2, r, "GAP", vDim, vBg, vBodySize, text.align_left)
        vCell(vDash, 3, r, "WHERE", vDim, vBg, vBodySize, text.align_right)
        r += 1
        vLane(vDash, r, tfA, laneSideA, laneDistA, laneHiA, laneLoA)
        r += 1
        vLane(vDash, r, tfB, laneSideB, laneDistB, laneHiB, laneLoB)
        r += 1
        vLane(vDash, r, tfC, laneSideC, laneDistC, laneHiC, laneLoC)
        r += 1
        vBandRow(vDash, r, "CHECKS" + sideTag + " · " + (gateCount == 0 ? "none required" : str.tostring(gatePassed) + " of " + str.tostring(gateCount) + " required met"))
        r += 1
        array<string> cNames = array.from("Session " + sess, "HTF gap overlap", "Sweep", smtName, "Day bias", "Displacement")
        array<bool> cPass = array.from(factorSession, factorContext, factorSweep, factorSMT, factorBias, factorDisplacement)
        array<bool> cGate = array.from(sessionGate, htfGate, sweepGate, smtGate, biasGate, displacementGate)
        array<bool> cAvail = array.from(true, true, true, smtAvailable, true, true)
        array<string> cTips = array.from("The configured New York entry session. Clock follows America/New_York daylight saving rules.", "A still-valid gap on an enabled higher frame, pointing the same way, overlapping the current closed candle. Updates every bar; not a frozen entry-time explanation.", "A recent sweep of a prior rolling high or low: a mechanical proxy for liquidity.", "Synchronised prior-bar comparison against the chosen symbol. Missing data never passes a required SMT check.", "Direction of the previous completed daily candle: a deliberately simple bias.", "Directional displacement on the analysis bar. A rule match, not a probability.")
        for pass = 0 to 1
            for k = 0 to 5
                bool gate = array.get(cGate, k)
                if (pass == 0 and gate) or (pass == 1 and not gate)
                    vCheck(vDash, r, array.get(cNames, k), array.get(cPass, k), gate, array.get(cAvail, k), array.get(cTips, k))
                    r += 1
        vBandRow(vDash, r, paperActive ? "PLAN · ACTIVE" : "PLAN · what the rule does next")
        r += 1
        if paperActive
            vPlanRow(vDash, r, "entry", vPx(paperEntry) + " · " + vSideLower(paperDir) + " · " + str.format_time(nz(paperEntryTime, vClock), "HH:mm", NY_TZ) + " NY", vColor(paperDir), "Accepted signal close.")
            r += 1
            vPlanRow(vDash, r, "stop", vPx(paperStop) + " · " + vPx(paperRisk) + " pts · 1R", vText, "Swing invalidation with tick buffer. Fixed from the signal close.")
            r += 1
            vPlanRow(vDash, r, "target", vPx(paperTarget) + " · " + vPx(math.abs(paperTarget - paperEntry)) + " pts · " + str.tostring(actualPlanR, "0.##") + "R", vText, "Fixed-R reference; outward tick rounding can make it slightly more than the input.")
            r += 1
            vPlanRow(vDash, r, "size", (na(paperQty) ? "—" : str.tostring(paperQty, "0.########")) + " · risk " + str.tostring(cashRisk, "0.##") + " " + syminfo.currency, vMuted, "Quantity = cash risk ÷ (stop distance × point value), floored to the quantity step. Symbol currency; no fees, no FX conversion.")
            r += 1
        else
            vPlanRow(vDash, r, "entry", (entryMode == "Inversion close" ? "the close that inverts a gap" : "a retest of the IFVG + rejecting close") + (sessionGate ? " · inside " + sess : ""), vText, "Entry trigger per the selected mode. Failed checks never create a late entry.")
            r += 1
            vPlanRow(vDash, r, "stop", "beyond the prior " + str.tostring(swingLookback) + "-bar swing + " + str.tostring(stopTicks) + (stopTicks == 1 ? " tick" : " ticks"), vText, "Wrong-side or zero-distance stops reject the plan.")
            r += 1
            vPlanRow(vDash, r, "target", str.tostring(rewardR, "0.##") + "R", vText, "Fixed multiple of the initial risk.")
            r += 1
            vPlanRow(vDash, r, "size", str.tostring(cashRisk, "0.##") + " " + syminfo.currency + " risk ÷ stop distance", vMuted, "Quantity = cash risk ÷ (stop distance × point value), floored to the quantity step.")
            r += 1
        vMerged(vDash, r, "Today " + str.tostring(dayResolved) + " of " + str.tostring(maxTradesDay) + (maxTradesDay == 1 ? " plan" : " plans") + " used" + (lastTxt != "" ? " · last " + lastTxt : ""), vMuted, vBg, vBodySize, text.align_left, "Resolved paper plans on this New York date against the daily cap; the last completed plan and its outcome.")
        r += 1
        if vDashMode == "Full"
            vBandRow(vDash, r, "RECORD · loaded history · no costs")
            r += 1
            vMerged(vDash, r, str.tostring(paperTrades) + (paperTrades == 1 ? " plan" : " plans") + " · " + str.tostring(paperWins) + " closed positive · " + vNum(paperNetR) + "R total", vMuted, vBg, vBodySize, text.align_left, "Deterministic paper record on the loaded chart, before costs. Same-bar stop/target collisions count as stops (" + str.tostring(paperAmbiguous) + " so far). A description of the past, not an edge.")
            r += 1
            string auditRefText = auditEnabled ? "Setup history · " + str.tostring(array.size(auditTrail)) + " records" : "Setup history · recording off"
            string auditRefTip = "Enable Show setup history to browse saved decision reasons. Hover a history row for its full Setup ID; use the exact ID filter to follow one setup."
            if array.size(auditTrail) > 0
                AuditEvent latestAudit = array.last(auditTrail)
                auditRefText += " · latest " + vAuditShortId(latestAudit.setupId)
                auditRefTip += "\n\n" + vAuditTip(latestAudit)
            vMerged(vDash, r, auditRefText, vMuted, vBg, size.tiny, text.align_left, auditRefTip)
            r += 1
            if vTape
                vBandRow(vDash, r, "EVENT TAPE · New York time")
                r += 1
                if array.size(eventTape) > 0
                    for j = 0 to array.size(eventTape) - 1
                        Event e = array.get(eventTape, j)
                        string m = e.message
                        string g = str.contains(m, "confirmed") ? "⚡ " : str.startswith(m, "LONG") ? "▲ " : str.startswith(m, "SHORT") ? "▼ " : str.contains(m, "TARGET") ? "✓ " : str.contains(m, "STOP") ? "✕ " : "• "
                        table.merge_cells(vDash, 1, r, 3, r)
                        color bg = j % 2 == 0 ? vBg : vStripe
                        vCell(vDash, 0, r, str.format_time(e.stamp, "HH:mm", NY_TZ), vMuted, bg, vBodySize, text.align_right)
                        vCell(vDash, 1, r, g + m, vColor(e.dir), bg, vBodySize, text.align_left)
                        r += 1
        vMerged(vDash, r, "Rules, not odds · ProEA Lab · open source · MIT", vDim, vBg, size.tiny, text.align_center)
    if vNarr != "Off"
        string nowText = paperActive ? (paperDir == 1 ? "Long" : "Short") + " plan active from " + vPx(paperEntry) + ".\nStop " + vPx(paperStop) + " (" + vPx(paperRisk) + " pts) · target " + vPx(paperTarget) + " (" + str.tostring(actualPlanR, "0.##") + "R planned)." : dayLocked ? "Today's " + (maxTradesDay == 1 ? "plan is used" : "plans are used") + (lastTxt != "" ? " (last: " + lastTxt + ")" : "") + ".\nEntries resume on the next New York day." : aDir != 0 ? "A " + vZoneWord(aDir) + " IFVG is armed at " + vPx(aLo) + "–" + vPx(aHi) + ".\n" + (entryMode == "Inversion close" ? "Its flip close was not entered. Only a new inversion close can enter." : gateWhy != "" ? "Not entered: " + gateWhy + "." : "Waiting for a retest with a rejecting close.") : freshCount > 0 ? "No inverted gap yet. " + str.tostring(freshCount) + (freshCount == 1 ? " fresh gap is" : " fresh gaps are") + " on the chart;\na close through one of them would arm it." : "No gap on the chart yet.\nWaiting for a confirmed three-candle gap."
        string reqText = ""
        string optText = ""
        if sessionGate
            reqText := vJoin(reqText, "session " + vMark(factorSession, true))
        else
            optText := vJoin(optText, "session " + vMark(factorSession, true))
        if htfGate
            reqText := vJoin(reqText, "HTF gap " + vMark(factorContext, true))
        else
            optText := vJoin(optText, "HTF gap " + vMark(factorContext, true))
        if sweepGate
            reqText := vJoin(reqText, "sweep " + vMark(factorSweep, true))
        else
            optText := vJoin(optText, "sweep " + vMark(factorSweep, true))
        if smtGate
            reqText := vJoin(reqText, "SMT " + vMark(factorSMT, smtAvailable))
        else
            optText := vJoin(optText, "SMT " + vMark(factorSMT, smtAvailable))
        if biasGate
            reqText := vJoin(reqText, "day bias " + vMark(factorBias, true))
        else
            optText := vJoin(optText, "\n          day bias " + vMark(factorBias, true))
        if displacementGate
            reqText := vJoin(reqText, "displacement " + vMark(factorDisplacement, true))
        else
            optText := vJoin(optText, "displacement " + vMark(factorDisplacement, true))
        string whyText = "Required: " + (reqText == "" ? "none — every inversion close is taken" : reqText) + ".\nOptional: " + (optText == "" ? "none" : optText) + "."
        string sessClause = sessionGate ? " inside " + sess : ""
        string planText = paperActive ? "Stop " + vPx(paperStop) + " / target " + vPx(paperTarget) + " are fixed from the signal close.\nExit at stop, target, or after " + str.tostring(maxHoldBars) + " bars" + (closeAtSessionEnd ? ", or at session end." : ".") : dayLocked ? "No further entries today.\nContext keeps updating." : (entryMode == "Inversion close" ? "If a bar closes through a gap's far edge" + sessClause + " → enter at that close." : aDir != 0 ? "If a bar retests " + vPx(aLo) + "–" + vPx(aHi) + " and closes back " + (aDir == 1 ? "above" : "below") + " it" + sessClause + " → " + vSideLower(aDir) + " at that close." : "If a bar retests an inverted gap and closes back beyond it" + sessClause + " → enter at that close.") + "\nStop beyond the prior " + str.tostring(swingLookback) + "-bar swing + " + str.tostring(stopTicks) + " tick · target " + str.tostring(rewardR, "0.##") + "R · size from " + str.tostring(cashRisk, "0.##") + " " + syminfo.currency + "."
        string limitText = "Higher-frame context waits for the closed candle: delay by design.\nNo news, order book, or intrabar path.\nChecks are rules, not odds."
        string recordText = str.tostring(paperTrades) + " plans resolved on this chart · " + vNum(paperNetR) + "R before costs.\n" + (paperTrades < 30 ? "Small sample; a description of the past, not an edge." : "Loaded history only; no forward validation.")
        string fullText = "IFVG / CO-PILOT · " + heroTxt + "\nNOW  " + nowText
        if vNarr != "Brief" and vWhyOn
            fullText += "\nWHY  " + whyText
        if vNarr != "Brief" and vPlanOn
            fullText += "\nPLAN " + planText
        if vNarr == "Detailed" and vLimitsOn
            fullText += "\nLIMIT " + limitText + "\nRECORD " + recordText
        fullText += "\nContext and rules; not a prediction."
        if IS_STRATEGY
            if na(vStrategyNarr)
                vStrategyNarr := label.new(bar_index, high, "", xloc = xloc.bar_index, style = label.style_label_left)
            int nx = str.contains(vNarrPos, "Left") ? math.max(0, bar_index - 80) : bar_index + 3
            float ny = str.contains(vNarrPos, "Top") ? vNarrHigh + vAtr : str.contains(vNarrPos, "Bottom") ? vNarrLow - vAtr : (vNarrHigh + vNarrLow) * 0.5
            label.set_xy(vStrategyNarr, nx, ny)
            label.set_text(vStrategyNarr, fullText)
            label.set_color(vStrategyNarr, vBg)
            label.set_textcolor(vStrategyNarr, vText)
            label.set_size(vStrategyNarr, vNarrBody)
        else
            table.merge_cells(vNarrTable, 0, 0, 1, 0)
            vCell(vNarrTable, 0, 0, "◆ CO-PILOT / " + heroTxt, vAccent, vBand, size.small, text.align_left)
            vCell(vNarrTable, 0, 1, "NOW", vAccent, vBg, vNarrBody, text.align_left)
            vCell(vNarrTable, 1, 1, nowText, vText, vBg, vNarrBody, text.align_left)
            if vNarr != "Brief" and vWhyOn
                vCell(vNarrTable, 0, 2, "WHY", vAccent, vStripe, vNarrBody, text.align_left)
                vCell(vNarrTable, 1, 2, whyText, vText, vStripe, vNarrBody, text.align_left)
            if vNarr != "Brief" and vPlanOn
                vCell(vNarrTable, 0, 3, "PLAN", vAccent, vBg, vNarrBody, text.align_left)
                vCell(vNarrTable, 1, 3, planText, vText, vBg, vNarrBody, text.align_left)
            if vNarr == "Detailed" and vLimitsOn
                vCell(vNarrTable, 0, 4, "LIMIT", vAccent, vStripe, vNarrBody, text.align_left)
                vCell(vNarrTable, 1, 4, limitText, vMuted, vStripe, vNarrBody, text.align_left)
                vCell(vNarrTable, 0, 5, "RECORD", vAccent, vBg, vNarrBody, text.align_left)
                vCell(vNarrTable, 1, 5, recordText, vMuted, vBg, vNarrBody, text.align_left)
            table.merge_cells(vNarrTable, 0, 7, 1, 7)
            vCell(vNarrTable, 0, 7, "Context and rules; not a prediction.", vMuted, vBg, vNarrBody, text.align_left)
Download companion .pine

v1.2.0 source · display-fix.1 · MIT licence

Updating an earlier copy?

Replace the whole script, check saved inputs and recreate alerts. Recording starts on; Show setup history and Pine Logs export start off. The added setup_id connects accepted-entry alerts to history. Audit controls do not change entry selection or the paper rule. The web revision restores precise inversion-close wording and labels positive paper closes correctly. Its three display lines differ from the original v1.2.0 package; the rules remain unchanged. Original producer package.

See the Pine Editor walkthrough
What differs in the companion?

The signal engine is shared. Factory broker costs are 2.25 cash per contract/unit and one tick of slippage, with margin set to five percent. Adapt costs, margin, point value and quantity step to the instrument. Stop and target are submitted with entry; broker fills and the cost-free paper ledger can differ.

Go deeper, one question at a time.

The complete rule and all settings stay here.

What happens from gap to finished plan?
01 · GAP

A space between candles.

The third candle closes with its low strictly above the high two bars earlier, or the bearish mirror. Minimum size and the optional middle-candle body filter decide whether the gap is retained.

02 · FLIP

A later close changes its role.

A strict close through the far edge inverts the gap: bearish becomes bullish, or bullish becomes bearish. A wick or equality does not flip it. CE is the midpoint, not a separate entry trigger.

03 · PLAN

The trigger must qualify.

Inversion-close mode evaluates that flip bar only. Retest mode waits for a later overlapping candle that closes beyond the trade-side edge with a directional body. Every required check and the risk plan must pass there.

What happens after a flip or entry?

An armed zone invalidates on a strict close through its opposite boundary. Equality survives. Fresh and armed zones expire when age exceeds the maximum; expiry is checked first. A zone flips once. Used, invalid and expired zones remain terminal.

Entry is the accepted close. The stop uses the prior rolling swing, excluding that entry bar, plus a tick buffer. Stop and target round outward to exchange ticks. Size rounds down from cash risk divided by stop distance and point value; missing history, wrong-side or sub-tick risk and zero size reject the plan.

Paper outcomes start on the next bar. Opening stop gaps exit at the open; favorable target gaps use the target. Otherwise a candle touching both levels resolves stop-first. Timeout and optional session exit follow price checks. One plan is active at a time; no new plan opens on its resolution bar. The daily cap counts resolved plans on their New York exit date.

When several zones qualify, the latest inversion wins; equal flip times choose the newest birth. The retention cap removes the oldest zone regardless of its state.

What do the six checks actually require?

Six factors stay visible. A required factor can block entry; an optional factor cannot. A count of matching checks is not a probability.

Higher-frame overlap
The entry candle overlaps any still-valid, same-direction gap in an enabled lane. Edge contact counts. The nearest displayed gap is only a summary.
Recent sweep
A wick strictly takes a prior rolling extreme, then closes strictly back inside. Default reference: twenty earlier bars; recent on the sweep bar and the ten bars after it.
SMT comparison
The previous chart bar makes a new rolling extreme; the synchronized comparator does not confirm it. Direct compares like extremes; Inverse swaps sides. Missing or stale data never passes.
Previous-day direction
The completed daily candle must point the same way. A doji is neutral. This is a separate factor, not the direction of the nearest gap.
Entry displacement
The entry body must point the trade’s way and reach at least 0.5 ATR by default. This differs from the optional middle-candle filter used when a gap is born.
New York session
Required by default: bar opens at or after 09:30 and before 11:00 New York. Changing the chart display timezone does not change this clock.
How the three higher-timeframe lanes work

On 1m or 3m charts, Auto uses 5 / 15 / 30m; on 5m, it uses 15 / 30 / 60m. All three must satisfy chart < A < B < C, even if a lane is disabled. A required context gate needs at least one enabled lane.

Each lane receives completed source candles, keeps valid gaps and shows the nearest one to the closed price. Equal-distance ties choose the latest gap. Its ALIGNED mark compares its direction with the analysed side; WHERE shows inside or distance in ATR. The entry check searches all aligned gaps, so another gap may qualify even when the nearest row is opposite.

Context gaps expire by source-bar age or a strict far-edge close. Their values arrive after confirmation, with deliberate delay. Current factor rows describe the latest closed candle, not a frozen explanation of an older entry.

How do I read every dashboard row?

WAIT has no armed zone. ARMED waits for a qualifying trigger. PLAN ACTIVE manages fixed levels. DAY DONE resumes entries on the next New York day.

STATE & RIBBON

What happens next?

The reason names the blocker or active levels. GAP → FLIP → CHECKS → PLAN lights the actual progress. Required checks appear first, with met, not met or no data.

PLAN & TODAY

Which levels are fixed?

Entry, stop, target, distances and size describe the accepted plan. When idle, the rows explain the rule. Today counts resolved plans; Full rows add the paper record and six-event tape.

CO-PILOT

The same read in words.

NOW gives the state and reason. WHY names required and optional checks. PLAN says if → then with levels. Detailed adds boundaries; Brief keeps the immediate read.

What does each setting change?
Inversion closeSession gate onlyPrior ten-bar stop + one tick1R targetOne resolved plan / NY day
Gap engine & context

ATR 14, minimum gap 0.05 ATR, middle-candle displacement off. Chart zones age out after 180 bars, with 40 retained. Context age is 80 source bars. Adjust lane timeframes and switches separately from the entry gates.

Checks & sizing

Optional gates are HTF overlap, sweep, SMT, day bias and entry displacement. SMT defaults to ES, Direct, with a ten-bar reference. Select a valid synchronized comparator. Cash risk defaults to 100 in symbol currency, quantity step 1, timeout 120 bars; session exit is off. Sizing does not convert account currency or enforce your broker’s restrictions.

Display & history

Full, Focus and Minimal change visibility, not retained-zone selection. Theme, dashboard, Co-Pilot and zone controls remain separate. The indicator starts Medium, Compact, Top Right, with Standard Co-Pilot Bottom Right; the companion starts Minimal with both panels off. Allow right margin for projected tags.

V1.1 display fixes retained the unchanged v1.0.1 engine. V1.2 adds frozen decision snapshots and optional history/export. Record setup history is on; Show setup history is off. Its panel displays eight events per page and starts Bottom Left, moving around occupied visible panels.

All sixty-three inputs appear below in source order. Defaults are for the indicator; companion differences are shown separately.

Gap engine

Birth uses the outer candles and optional size/body filters. Each zone flips once; the oldest retained zone is removed when capacity is exceeded.

Enter on the flip or a later retest
Inversion close
Candles used to measure movement
14
Smallest gap to keep
0.05 ATR
Minimum middle-candle body; zero disables
0 ATR
Expire fresh/armed zones after this age
180 chart bars
Maximum retained chart zones
40

Higher-timeframe context

Auto builds three increasing frames above your chart. Even disabled lanes must have valid frame ordering. Each lane keeps at most twelve gaps.

Lane A timeframe
Auto
Lane B timeframe
Auto
Lane C timeframe
Auto
Use lane A in the context check
On
Use lane B in the context check
On
Use lane C in the context check
On
Maximum context-gap age
80 source bars

Required entry checks

Every factor is observed. Only checks marked required can block entry. The session is the only required check by default.

Require same-direction context overlap
Off
Require a recent sweep
Off
Earlier candles defining a sweep extreme
20
Candles after a sweep that still count as recent
10
Require a strong directional entry body
Off
Minimum directional entry body
0.5 ATR
Require the previous daily candle to agree
Off
Require a synchronized market comparison
Off
Market used for the SMT comparison
CME_MINI:ES1!
Compare like or opposite extremes
Direct
Prior candles for the SMT extreme
10
Require the New York entry window
On
Entry window in America/New_York
09:30–11:00

Risk and paper record

Stops exclude the entry candle. Sizing uses symbol currency and point value, with no account-currency conversion. Broker restrictions may differ.

Cash budget for initial stop risk
100
Target distance relative to initial risk
1R
Earlier candles used to place the stop
10
Buffer beyond the prior swing
1 tick
Round size down to this increment
1
Resolved-plan allowance per New York exit day
1
Maximum candles held after entry
120
Exit on a bar outside the session
Off

Entry alerts

The dynamic message describes an accepted paper plan. It is separate from the indicator’s named lifecycle conditions.

Send structured accepted-entry messages
On

Record a setup’s original decisions

Confirmed events keep their reasons within a run; recalculation rebuilds them. Capacity is 50–500 events across all setups. Filtering neither reserves storage nor changes recording or entry selection. Use the full ID. Export needs recording On and an editable personal copy; it writes to Pine Logs only.

Record setup history
On
Retained audit events
300
Setup ID filter · blank = all
Blank · all setups
Write bug report to Pine Logs
Off

Chart and dashboard

Display controls do not change the entry rule. Visibility limits hide drawings while retained zones remain in the engine.

Choose Aurora, Royal Gold or Colorblind safe
Aurora
Choose Full, Focus or Minimal chart detail
FullCompanion: Minimal
Show the dashboard
OnCompanion: Off
Choose Compact or Full dashboard rows
Compact
Place the dashboard
Top Right
Size the dashboard text
Medium
Visible entry-chart zones
10
Visible retired zones
3
Maximum drawing distance from price
8 ATR
Completed plans shown besides the active plan
3
Project the three context gaps
On
Show 09:30 open and prior-day high/low
On
Draw the New York session range
On
Show inversion marks and entry tickets
On
Show the six-event tape in Full rows
On

Co-Pilot

Brief gives the immediate read; Standard explains checks and plan; Detailed adds limits. A shared dashboard corner moves this panel to the opposite lower corner.

Choose Off, Brief, Standard or Detailed
StandardCompanion: Off
Place the explanation
Bottom Right
Size the explanation text
Medium
Include WHY
On
Include PLAN
On
Include data limits
On

Browse the saved events

The panel starts hidden, independently of recording and the dashboard. Eight events per page, newest first; excessive page numbers clamp. Occupied panel positions move automatically. Rows use New York time; JSON timestamps are UTC.

Show setup history
Off
Setup history position
Bottom Left
Setup history page · newest first
1
Why did this setup enter or get skipped?

Each recorded gap gets one Setup ID. Its saved events explain what happened at the close when each event occurred. Later checklist changes do not rewrite those reasons within the same calculation.

ONE GAP · SAME SETUP ID

  1. 01

    Gap found

    Save its original edges and birth time.

  2. 02

    Gap flips

    Save the close that crossed its far edge.

  3. 03

    Decision saved

    Read why the trigger was accepted or skipped.

  4. 04

    Ending explained

    A zone can end, or an accepted paper plan can resolve.

Possible stages, not a promised path or four separate candles. A gap can expire before it flips. Retest mode also records an eligible overlap that fails its rejection close; ordinary waiting and non-overlap bars add no rows.

Open the history in TradingView

  1. Keep Record setup history on. Turn on Show setup history; the panel starts hidden.
  2. Read eight events per page, newest first. Row times use New York time. Hover for the full saved reason and Setup ID.
  3. Paste that full ID into Setup ID filter · blank = all. The shortened row ID will not match. The filter changes the panel and report, while recording and entry selection still cover every setup.

300 events across all setups are retained by default, adjustable from 50 to 500. This is separate from the zone limit. Check the removed-record count: an older birth or decision may be gone even when you filter its ID.

Actual IFVG Desk v1.2.0 on MNQ five-minute candles: the bottom-left setup history shows saved events and reasons, eight of 300 retained records, and a count of older records removed.
Actual v1.2.0 history panel · MNQ 5m · history and export enabled for this capture. Open full-size ↗

Copy the history and settings

  1. Use an editable personal copy in Pine Editor. Open Pine Logs from its More menu; published studies cannot produce these logs.
  2. With recording on, enable Write bug report to Pine Logs. Copy the report header/settings and matching event JSON lines. The export runs oldest first after historical calculation; later matching events append at their confirmed closes.
  3. Keep the JSON timestamps. A batch log prefix marks export time. Save the text before changing the chart or settings: recalculation rebuilds history from available bars. The switch does not create a permanent file or send a report.

These are paper events, with costs excluded. A resolution event’s detail retains the original accepted plan; candidate_plan is a fresh hypothetical plan for that event bar. Broker fills and Strategy Properties remain separate.

Which alert means an entry was accepted?

Seven named indicator conditions: long plan, short plan, new gap, inversion, invalidation, expiry and paper resolution. Only accepted-plan events mean the required checks and risk rules passed.

In TradingView’s alert dialog they are named IFVG Desk · Long, IFVG Desk · Short, IFVG Desk · Inversion, IFVG Desk · New gap, IFVG Desk · IFVG invalidated, IFVG Desk · Zone expired and IFVG Desk · Paper resolved.

“Any alert() function call” sends one structured accepted-entry event at the confirmed close. It includes setup_id for matching the audit, source zone, the existing entry-event id, direction, levels, size, factor states and enabled gates. The audit has a separate event_id. It describes a paper plan, not a broker connection. Strategy users can use alert() or broker order-fill alerts; indicator alertconditions are separate. Recreate alerts after changing inputs.

What was checked, and what was not?

The producer reports 88 local tests, nine native Pine probe checks and compilation of both scripts plus the probe without errors or warnings. These establish bounded program behavior; they do not establish a trading advantage.

Before/after observers matched on the same 5,888 historical bars in both entry modes. The real history panel and exported JSON were checked. This is a finite regression sample, not proof across every feed, input or recalculation. These are supplied evidence; the nine runtime checks were not rerun here. Both web files were freshly compiled after the display correction: zero errors or warnings.

The companion’s broker test was not repeated for v1.2. Earlier cross-chart findings remain historical; ETF charts disagreed and paper/broker records were not reconciled trade by trade. The paper ledger excludes costs and resolves unknown stop/target order stop-first.

What are the limits?
READ THE BOUNDARIES

A readable rule, with clear limits.

  • This is an original implementation of public IFVG concepts, not the private code behind a trade recap.
  • Confirmation adds delay. Loaded history, revised feed data and changed settings can alter the recalculated study.
  • An armed zone and an observed factor are context. Only a qualifying trigger creates a paper plan.
  • The paper model cannot know the order of touches inside a candle. A broker simulation does not establish an executable external fill.
  • Trading risk is entirely yours. This is context for your decisions, not financial advice.
What should I know before the first chart?
Does it use forming candles?

Gap birth, inversion, entry and lifecycle updates commit on closed candles. Higher-timeframe context uses completed source bars. The flip is recorded at its confirming close, never backdated.

Why is a bullish IFVG not a long entry?

ARMED is a zone state. The entry mode, enabled gates, available data, daily allowance and risk validity still decide. Inversion-close mode does not queue a failed flip for later.

Which charts are supported?

Standard time-based intraday candles, with all context frames strictly increasing above the chart. The tool refuses non-standard charts and invalid frame ordering with an explanatory error.

Why does SMT say no data?

The comparator must have matching timestamps and complete prior-bar references. A valid but unavailable feed is different from an invalid symbol. A required SMT check blocks while synchronized data is missing.

Can an invalid gap flip back again?

No. Each zone inverts once. Invalid, expired and used zones are terminal; a new gap must start a new lifecycle.

  • SMC Basics

    Start with a basic fair value gap before studying what changes when its boundary fails.

  • Sweep Desk

    Compare IFVG checks with a broader sequence that starts from a liquidity sweep.

Built by ProEA Lab. Compare all free indicators.

05 · THE LOG

Every change, dated

What shipped and what changed in IFVG 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. Trace a setup’s original decisions v1.2.0 Updated IFVG Desk

    Saved reasons by setup ID, with optional history and Pine Logs.

    • Recording on; panel/export off. Global rolling cap: 300 events (50–500). Full-ID filters change neither entries nor storage
    • Saved skipped decisions and paper outcomes; recalculation rebuilds history
    • Rules unchanged; prior display corrections retained
    • Producer reports 88 local tests and 9 native probe rows; companion broker simulation was not rerun
  2. Historical v1.1.2 armed-state and record wording Lab note IFVG Desk

    Earlier display corrections remain in v1.2.

    • Armed means waiting: only a new inversion close can enter
    • “Closed positive” includes positive-R timeouts/session closes, not only targets. Engine, signals and alerts unchanged
  3. IFVG Desk v1.1.1 Shipped

    Shows when a gap flips direction, which checks are required, and when a plan can start.

BUILT WITH TRADERS

Something to change in IFVG 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.

THE FRAMEWORK BEHIND THIS FREE TOOLBuild a tool you can explain.The six-kit framework is $46 once, with 5 finished indicators included.
© 2026 ProEA · All rights reservedBuilt for MT5 · Not affiliated with MetaQuotes