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.
IFVG Desk v1.2.0 · Free & open-source
See the gap form, flip and pass or fail its checks.
Indicator + strategy companion · MIT · No signup
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.
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
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
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
Mark a three-candle gap, then watch for a closing break.
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.
It makes entry, blockers and exits testable. Clear rules alone do not establish an advantage after costs.
Reveal candle four, then compare the choices.
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.
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.
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

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 ↗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.
Only the flip-test candle is edited. The later return is not shown yet.
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
Two complete files. No signup needed.
Use standard intraday candles. Start with 1m and Auto context lanes.
Replace the sample code, save and add to chart.
The entry session defaults to 09:30–11:00 New York.
Copy the code as it is. No coding needed.
//@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)
Use a separate chart, match engine settings and adapt costs to your instrument.
Copy the code as it is. No coding needed.
//@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)
v1.2.0 source · display-fix.1 · MIT licence
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.
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.
The complete rule and all settings stay here.
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.
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.
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.
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.
Six factors stay visible. A required factor can block entry; an optional factor cannot. A count of matching checks is not a probability.
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.
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.
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.
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.
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.
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.
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.
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.
Birth uses the outer candles and optional size/body filters. Each zone flips once; the oldest retained zone is removed when capacity is exceeded.
Auto builds three increasing frames above your chart. Even disabled lanes must have valid frame ordering. Each lane keeps at most twelve gaps.
Every factor is observed. Only checks marked required can block entry. The session is the only required check by default.
Stops exclude the entry candle. Sizing uses symbol currency and point value, with no account-currency conversion. Broker restrictions may differ.
The dynamic message describes an accepted paper plan. It is separate from the indicator’s named lifecycle conditions.
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.
Display controls do not change the entry rule. Visibility limits hide drawings while retained zones remain in the engine.
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.
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.
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
Save its original edges and birth time.
Save the close that crossed its far edge.
Read why the trigger was accepted or skipped.
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.
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.

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.
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.
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.
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.
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.
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.
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.
No. Each zone inverts once. Invalid, expired and used zones are terminal; a new gap must start a new lifecycle.
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.
Trace a setup’s original decisions v1.2.0 Updated IFVG Desk
Saved reasons by setup ID, with optional history and Pine Logs.
Historical v1.1.2 armed-state and record wording Lab note IFVG Desk
Earlier display corrections remain in v1.2.
IFVG Desk v1.1.1 Shipped
Shows when a gap flips direction, which checks are required, and when a plan can start.
A small friction or a big idea. Tell us what would make this tool work better for you.
Requests for a new tool go on the same queue: see what is asked for and what is being built.