Description

for everyone, ifvg just automated.

Comments (0)

0/2000

Loading comments…

Source code

// ═══════════════════════════════════════════════════════════════════
// ICT IFVG Sweep Strategy — AUTOMATED (v11) — RR-based
// ═══════════════════════════════════════════════════════════════════
// Full-automation build of the v8 indicator. Detection, internal H/L
// pivots, bias table, and rendering are preserved verbatim; a live-
// trading execution layer is added on top that fires
// ctx.strategy.entry() with bracket orders when a signal locks in on
// the just-confirmed bar.
//
// SL/TP is RR-BASED (matches v8):
//   Bull  → SL = FVG bottom − stopBuffer, TP = entry + risk × R:R
//   Bear  → SL = FVG top    + stopBuffer, TP = entry − risk × R:R
//
// Defaults reflect the settings from the strategy-settings panel you
// screenshotted. "Enable Live Trading" defaults OFF for safety; flip
// it to ON in the strategy panel when you're ready. Backtests bypass
// that flag so historical runs always produce trades.
//
// ZERO-REPAINT: every detection uses only the CONFIRMED bar. Live bar
// is never referenced in signal generation, and entries fire only on
// the exact confirmed bar the inversion locked in — no re-firing of
// historical signals on hot reload.
// ═══════════════════════════════════════════════════════════════════

function calculate(bars, ctx) {
  // ═══════════════════════════════════════════════════════════════════
  // EXECUTION INPUTS
  // ═══════════════════════════════════════════════════════════════════
  var enableTrading      = ctx.input('Enable Live Trading', false, { liveOnly: true });
  var contractQty        = ctx.input('Contracts', 1, { min: 1, max: 10, step: 1 });

  // ─── Trade frequency caps ───
  // oneTradePerSession takes precedence when ON (max = 1). When OFF,
  // maxTradesPerDay is the ceiling per session-key. Both counts reset
  // at the sessionReset time below.
  var oneTradePerSession = ctx.input('One Trade Per Session', true);
  var maxTradesPerDay    = ctx.input('Max Trades Per Day', 3, { min: 1, max: 20, step: 1 });
  var sessionResetHour   = ctx.input('Session Reset Hour (PT)', 6,  { min: 0, max: 23, step: 1 });
  var sessionResetMin    = ctx.input('Session Reset Minute',   30, { min: 0, max: 59, step: 1 });
  var cooldownHrs        = ctx.input('Rolling Cooldown Hours (0 = off)', 0, { min: 0, max: 48, step: 1 });

  // ─── Daily risk gates ───
  // Loss kill flattens the account and stops entries when day P&L drops
  // below the negative threshold. Profit target STOPS entries (does not
  // flatten) once day P&L exceeds it — locks in the win.
  var dailyLossKill      = ctx.input('Daily Loss Kill ($, 0 = off)', 0, { min: 0, max: 10000, step: 50 });
  var dailyProfitTarget  = ctx.input('Daily Profit Target ($, 0 = off)', 0, { min: 0, max: 10000, step: 50 });

  // ─── Time-based gates ───
  // Entry cutoff blocks NEW entries after this PT time each day. Existing
  // positions ride through (use Flatten at NY End / PM End to close them).
  var useEntryCutoff     = ctx.input('Enable Daily Entry Cutoff', false);
  var cutoffHour         = ctx.input('Entry Cutoff Hour (PT)', 12, { min: 0, max: 23, step: 1 });
  var cutoffMin          = ctx.input('Entry Cutoff Minute', 0, { min: 0, max: 59, step: 1 });

  var flattenAtNYEnd     = ctx.input('Flatten at NY End', false);
  var flattenAtPMEnd     = ctx.input('Flatten at PM Session End', false);

  // ─── Historical trade overlay ───
  // Default OFF — chart renders only CURRENT IFVG setups (like the v8
  // indicator). Flip ON to also overlay markers for past FIRED trades
  // pulled from state.firedIFVGs (only real fills, never candidate
  // signals). Historical markers are distinct — circles, not diamonds —
  // so they can't be confused with current-setup entries.
  var showFiredHistory   = ctx.input('Show Fired Trade History', false);

  // ═══════════════════════════════════════════════════════════════════
  // DETECTION INPUTS (defaults from your settings screenshots)
  // ═══════════════════════════════════════════════════════════════════
  var lookbackDays = ctx.input('Lookback Days', 3, { min: 1, max: 30, step: 1 });

  var sweepAsian   = ctx.input('Sweep Asian H/L', true);
  var sweepLondon  = ctx.input('Sweep London H/L', true);
  var sweepNY      = ctx.input('Sweep NY H/L', true);

  var asianStart  = ctx.input('Asian Start (ET)', 20, { min: 0, max: 23, step: 1 });
  var asianEnd    = ctx.input('Asian End (ET)', 0, { min: 0, max: 23, step: 1 });
  var londonStart = ctx.input('London Start (ET)', 2, { min: 0, max: 23, step: 1 });
  var londonEnd   = ctx.input('London End (ET)', 5, { min: 0, max: 23, step: 1 });
  var nyStart     = ctx.input('NY Start (ET)', 9, { min: 0, max: 23, step: 1 });
  var nyEnd       = ctx.input('NY End (ET)', 12, { min: 0, max: 23, step: 1 });

  var minSweepTicks       = ctx.input('Min Sweep Past Level (ticks)', 2, { min: 0, max: 50, step: 1 });
  var minGapTicks         = ctx.input('Min FVG Size (ticks)', 3, { min: 1, max: 100, step: 1 });
  var maxBarsForInversion = ctx.input('Max Bars For Inversion', 20, { min: 1, max: 50, step: 1 });
  var inversionMode       = ctx.input('Inversion Mode', 'close_only', { options: ['close_only', 'wick_or_close'] });
  var showMitigated       = ctx.input('Show Mitigated IFVGs', false);
  var showCELine          = ctx.input('Display Consequent Encroachment', true);
  var maxIFVGs            = ctx.input('Max IFVGs Displayed', 20, { min: 1, max: 50, step: 1 });

  var showEntrySignals = ctx.input('Show Entry Signals', true);
  var onlyPostSweep    = ctx.input('Entry Only After Sweep', false);

  // ─── Entry sizing ───
  // Three modes for placing SL and TP:
  //   'rr_from_ifvg'  → SL = FVG boundary ± stopBuffer, TP = risk × R:R
  //                     (original v11 behavior — SL size depends on IFVG)
  //   'fixed_points'  → SL and TP are both explicit point distances
  //                     from entry — IFVG geometry is ignored
  //   'rr_fixed_sl'   → SL is fixed points from entry, TP = SL × R:R
  //                     (SL is user-controlled, TP scales with R:R)
  var sizingMode       = ctx.input('Entry Sizing Mode', 'rr_from_ifvg', { options: ['rr_from_ifvg', 'fixed_points', 'rr_fixed_sl'] });
  var slPoints         = ctx.input('Stop Loss (points)', 25, { min: 1, max: 500, step: 1 });
  var tpPoints         = ctx.input('Take Profit (points)', 50, { min: 1, max: 500, step: 1 });
  var entryRR          = ctx.input('Target R:R', 2, { min: 0.5, max: 10, step: 0.5 });
  var stopBuffer       = ctx.input('Stop Buffer (ticks)', 2, { min: 0, max: 20, step: 1 });

  var showEntryLines   = ctx.input('Show Entry/SL/TP Lines', true);
  var entryColor       = ctx.input('Entry Signal Color', '#00e5ff');

  // ─── AM session scan window ───
  var useIFVGTimeFilter = ctx.input('Filter IFVG By Time', true);
  var ifvgStartHour     = ctx.input('AM Scan Start Hour (PT)', 6, { min: 0, max: 23, step: 1 });
  var ifvgStartMin      = ctx.input('AM Scan Start Minute', 30, { min: 0, max: 59, step: 1 });
  var ifvgEndHour       = ctx.input('AM Scan End Hour (PT)', 12, { min: 0, max: 23, step: 1 });
  var ifvgEndMin        = ctx.input('AM Scan End Minute', 0, { min: 0, max: 59, step: 1 });

  // ─── PM / Globex-open session scan window ───
  // Globex re-opens at 3:00 PM PT (after the 2–3 PM PT daily break) and
  // runs through the evening / overnight. Independent second window —
  // enable to also trade the PM open / evening range.
  var enablePMSession = ctx.input('Enable PM Session (Globex)', false);
  var pmStartHour     = ctx.input('PM Scan Start Hour (PT)', 15, { min: 0, max: 23, step: 1 });
  var pmStartMin      = ctx.input('PM Scan Start Minute', 0, { min: 0, max: 59, step: 1 });
  var pmEndHour       = ctx.input('PM Scan End Hour (PT)', 22, { min: 0, max: 23, step: 1 });
  var pmEndMin        = ctx.input('PM Scan End Minute', 0, { min: 0, max: 59, step: 1 });

  var showInternalHL = ctx.input('Show Internal H/L', false);
  var pivotStrength  = ctx.input('Pivot Strength (bars L/R)', 3, { min: 2, max: 10, step: 1 });
  var internalColor  = ctx.input('Internal H/L Color', '#b388ff');

  var biasHTF      = ctx.input('Bias HTF Timeframe', '1H');
  var biasDeltaLen = ctx.input('Bias Delta Lookback', 20, { min: 5, max: 100, step: 1 });
  var biasRSILen   = ctx.input('Bias RSI Period', 14, { min: 5, max: 50, step: 1 });

  var bullColor      = ctx.input('Bullish IFVG Color', '#26a69a');
  var bearColor      = ctx.input('Bearish IFVG Color', '#ef5350');
  var mitigatedColor = ctx.input('Mitigated IFVG Color', '#555555');
  var neutralColor   = '#ffeb3b';
  var levelColor     = ctx.input('Session Level Color', '#ffeb3b');
  var showLevels       = ctx.input('Show Session Levels', true);
  var showSweepArrows  = ctx.input('Show Sweep Arrows', true);
  var showLabels       = ctx.input('Show IFVG Labels', true);
  var showTable        = ctx.input('Show Status Table', true);

  // ═══════════════════════════════════════════════════════════════════
  // CONSTANTS
  // ═══════════════════════════════════════════════════════════════════
  var tickSize      = (ctx.syminfo && ctx.syminfo.tickSize) ? ctx.syminfo.tickSize : 0.25;
  var minSweepPrice = minSweepTicks * tickSize;
  var minGapPrice   = minGapTicks * tickSize;
  var cooldownMs    = cooldownHrs * 60 * 60 * 1000;
  var last          = bars.length - 1;
  var confirmed     = last - 1; // last CLOSED bar — live bar is NEVER used for detection

  if (confirmed < 2) return;

  // ═══════════════════════════════════════════════════════════════════
  // STATE INIT (persists across calls, per-strategy-instance)
  // ═══════════════════════════════════════════════════════════════════
  if (!ctx.state.firedIFVGs)                       ctx.state.firedIFVGs = {};
  if (!ctx.state.tradesPerSession)                 ctx.state.tradesPerSession = {}; // sessionKey → fire count
  if (ctx.state.lastEntryTs === undefined)         ctx.state.lastEntryTs = 0;
  if (ctx.state.lastEntrySessionKey === undefined) ctx.state.lastEntrySessionKey = '';
  if (ctx.state.killSwitchTripped === undefined)   ctx.state.killSwitchTripped = false;

  // Bracket state — this SDK places SL and TP as SEPARATE ctx.strategy.entry()
  // calls (type: 'stop' and type: 'limit'), NOT as a nested `bracket` option.
  // pendingEntry stores { side, sl, tp, key } locked in at fire time so the
  // next cycle can place the protective orders once the fill lands.
  // bracketPlaced flips to true when SL/TP orders are submitted, back to
  // false when the position closes (SL or TP hit → back to flat).
  if (ctx.state.pendingEntry === undefined)        ctx.state.pendingEntry = null;
  if (ctx.state.bracketPlaced === undefined)       ctx.state.bracketPlaced = false;

  // One-time cleanup: older builds accumulated a persistedEntries object
  // that has been retired. Delete it so state hygiene is preserved.
  if (ctx.state.persistedEntries) delete ctx.state.persistedEntries;
  if (ctx.state.signalStateVersion !== undefined) delete ctx.state.signalStateVersion;

  // ═══════════════════════════════════════════════════════════════════
  // HELPERS
  // ═══════════════════════════════════════════════════════════════════
  function inSession(barIndex, startHour, endHour) {
    var h = ctx.time.hourIn(barIndex, 'America/New_York');
    if (startHour < endHour) return h >= startHour && h < endHour;
    return h >= startHour || h < endHour;
  }

  // Returns 'AM' | 'PM' | null. Callers that only care about "in any scan
  // range" can just check truthiness. When filter is disabled, always
  // returns 'AM' so downstream logic treats every bar as inside a range.
  function windowFor(barIndex) {
    if (!useIFVGTimeFilter) return 'AM';
    var h = ctx.time.hourIn(barIndex, 'America/Los_Angeles');
    var m = ctx.time.minuteIn(barIndex, 'America/Los_Angeles');
    var t = h * 60 + m;
    var s = ifvgStartHour * 60 + ifvgStartMin;
    var e = ifvgEndHour * 60 + ifvgEndMin;
    var inAM = (s < e) ? (t >= s && t < e) : (t >= s || t < e);
    if (inAM) return 'AM';
    if (enablePMSession) {
      var ps = pmStartHour * 60 + pmStartMin;
      var pe = pmEndHour * 60 + pmEndMin;
      var inPM = (ps < pe) ? (t >= ps && t < pe) : (t >= ps || t < pe);
      if (inPM) return 'PM';
    }
    return null;
  }

  function inIFVGWindow(barIndex) { return windowFor(barIndex) !== null; }

  function hexRGBA(hex, alpha) {
    var r = parseInt(hex.slice(1, 3), 16);
    var g = parseInt(hex.slice(3, 5), 16);
    var b = parseInt(hex.slice(5, 7), 16);
    return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
  }

  function dt(type) { return type === 'NY' ? 'PD' : type; }

  // Session key for once-per-session dedup — a "session" is now
  // (trading-day, window). E.g. "20340_AM" and "20340_PM" are treated
  // as INDEPENDENT sessions, so with oneTradePerSession=true you get
  // one AM trade AND one PM trade per day (max 2). If PM is disabled,
  // windowFor() only ever returns 'AM' so behavior collapses back to
  // one trade per day.
  //
  // Returns null when the bar is outside every configured window —
  // callers should treat that as "not fireable" (no matching entry
  // will exist there anyway, since entries only lock inside a window).
  function sessionKeyFor(barIndex) {
    var win = windowFor(barIndex);
    if (!win) return null;
    var ts = +new Date(bars[barIndex].timestamp);
    var h  = ctx.time.hourIn(barIndex, 'America/Los_Angeles');
    var m  = ctx.time.minuteIn(barIndex, 'America/Los_Angeles');
    var localMin = h * 60 + m;
    var resetMin = sessionResetHour * 60 + sessionResetMin;
    var localMidnightMs = ts - localMin * 60 * 1000;
    if (localMin < resetMin) localMidnightMs -= 24 * 60 * 60 * 1000;
    return String(Math.floor(localMidnightMs / 86400000)) + '_' + win;
  }

  // ═══════════════════════════════════════════════════════════════════
  // KILL SWITCH — day-P&L floor and NY-close flatten
  // ═══════════════════════════════════════════════════════════════════
  var acct = ctx.strategy.account;
  if (dailyLossKill > 0 && acct && acct.dayPnl < -Math.abs(dailyLossKill) && !ctx.state.killSwitchTripped) {
    ctx.state.killSwitchTripped = true;
    ctx.log('🚨 DAILY LOSS KILL — flattening. dayPnl=' + acct.dayPnl);
    ctx.strategy.flattenAll();
  }
  if (flattenAtNYEnd && ctx.strategy.position && ctx.strategy.position.side !== 'flat') {
    var lastHour = ctx.time.hourIn(last, 'America/New_York');
    if (lastHour >= nyEnd) {
      ctx.log('NY end — flattening');
      ctx.strategy.flattenAll();
    }
  }
  if (flattenAtPMEnd && enablePMSession && ctx.strategy.position && ctx.strategy.position.side !== 'flat') {
    var lastHourPT = ctx.time.hourIn(last, 'America/Los_Angeles');
    var lastMinPT  = ctx.time.minuteIn(last, 'America/Los_Angeles');
    var lastMinsPT = lastHourPT * 60 + lastMinPT;
    var pmEndMins  = pmEndHour * 60 + pmEndMin;
    if (lastMinsPT >= pmEndMins) {
      ctx.log('PM end — flattening');
      ctx.strategy.flattenAll();
    }
  }

  // ╔══════════════════════════════════════════════════════════════╗
  // ║  LOOKBACK CUTOFF                                             ║
  // ╚══════════════════════════════════════════════════════════════╝
  var dayCount = 0, cutoffBar = 0;
  for (var i = confirmed; i >= 1; i--) {
    if (ctx.time.isNewSession(i)) {
      dayCount++;
      if (dayCount > lookbackDays) { cutoffBar = i; break; }
    }
  }

  // ╔══════════════════════════════════════════════════════════════╗
  // ║  PASS 1: SESSION BUILDING                                    ║
  // ╚══════════════════════════════════════════════════════════════╝
  var sessions = [];
  var curA = null, curL = null, curN = null;

  for (var i = cutoffBar; i <= confirmed; i++) {
    var inA  = inSession(i, asianStart, asianEnd);
    var inLn = inSession(i, londonStart, londonEnd);
    var inNy = inSession(i, nyStart, nyEnd);

    if (inA) {
      if (!curA) curA = { high: bars[i].high, low: bars[i].low, highBar: i, lowBar: i };
      else {
        if (bars[i].high > curA.high) { curA.high = bars[i].high; curA.highBar = i; }
        if (bars[i].low < curA.low)   { curA.low  = bars[i].low;  curA.lowBar  = i; }
      }
    } else if (curA) {
      if (sweepAsian) sessions.push({ type: 'Asia', high: curA.high, low: curA.low, endBar: i - 1, highBar: curA.highBar, lowBar: curA.lowBar });
      curA = null;
    }

    if (inLn) {
      if (!curL) curL = { high: bars[i].high, low: bars[i].low, highBar: i, lowBar: i };
      else {
        if (bars[i].high > curL.high) { curL.high = bars[i].high; curL.highBar = i; }
        if (bars[i].low < curL.low)   { curL.low  = bars[i].low;  curL.lowBar  = i; }
      }
    } else if (curL) {
      if (sweepLondon) sessions.push({ type: 'London', high: curL.high, low: curL.low, endBar: i - 1, highBar: curL.highBar, lowBar: curL.lowBar });
      curL = null;
    }

    if (inNy) {
      if (!curN) curN = { high: bars[i].high, low: bars[i].low, highBar: i, lowBar: i };
      else {
        if (bars[i].high > curN.high) { curN.high = bars[i].high; curN.highBar = i; }
        if (bars[i].low < curN.low)   { curN.low  = bars[i].low;  curN.lowBar  = i; }
      }
    } else if (curN) {
      if (sweepNY) sessions.push({ type: 'NY', high: curN.high, low: curN.low, endBar: i - 1, highBar: curN.highBar, lowBar: curN.lowBar });
      curN = null;
    }
  }

  // ╔══════════════════════════════════════════════════════════════╗
  // ║  PASS 2: SWEEP DETECTION                                     ║
  // ╚══════════════════════════════════════════════════════════════╝
  var sweeps = [];
  var sweepSet = {};

  for (var si = 0; si < sessions.length; si++) {
    var sess = sessions[si];
    for (var i2 = sess.endBar + 1; i2 <= confirmed; i2++) {
      var sk = sess.type + '-' + si;
      if (!sweepSet[sk + '-low']  && bars[i2].low  < sess.low  - minSweepPrice) {
        sweepSet[sk + '-low']  = true;
        sweeps.push({ bar: i2, side: 'low',  sessType: sess.type, level: sess.low,  sessIdx: si });
      }
      if (!sweepSet[sk + '-high'] && bars[i2].high > sess.high + minSweepPrice) {
        sweepSet[sk + '-high'] = true;
        sweeps.push({ bar: i2, side: 'high', sessType: sess.type, level: sess.high, sessIdx: si });
      }
    }
  }

  // ╔══════════════════════════════════════════════════════════════╗
  // ║  PASS 3: RAW FVG DETECTION                                   ║
  // ║  Detect ALL raw FVGs first so chain filter is accurate.      ║
  // ╚══════════════════════════════════════════════════════════════╝
  var rawFVGs = {};
  for (var i3 = cutoffBar + 2; i3 <= confirmed; i3++) {
    var c1 = i3 - 2;
    var beTop = bars[c1].low;
    var beBot = bars[i3].high;
    if (beTop - beBot >= minGapPrice) {
      rawFVGs['be-' + c1] = { dir: 'bear', c1: c1, c3: i3, top: beTop, bottom: beBot };
    }
    var buTop = bars[i3].low;
    var buBot = bars[c1].high;
    if (buTop - buBot >= minGapPrice) {
      rawFVGs['bu-' + c1] = { dir: 'bull', c1: c1, c3: i3, top: buTop, bottom: buBot };
    }
  }

  // ╔══════════════════════════════════════════════════════════════╗
  // ║  PASS 4: INVERSION + ENTRY (RR-based SL/TP)                  ║
  // ╚══════════════════════════════════════════════════════════════╝
  var ifvgs = {};
  var entries = {};

  for (var fk in rawFVGs) {
    var raw = rawFVGs[fk];
    var prefix = raw.dir === 'bear' ? 'be-' : 'bu-';
    // Chain filter — only the LAST FVG in a consecutive same-dir chain inverts.
    if (rawFVGs[prefix + (raw.c1 + 1)] || rawFVGs[prefix + (raw.c1 + 2)]) continue;

    var scanEnd = Math.min(raw.c3 + maxBarsForInversion, confirmed);
    for (var j = raw.c3 + 1; j <= scanEnd; j++) {
      var inverted = false;
      if (raw.dir === 'bear') {
        inverted = inversionMode === 'close_only' ? bars[j].close > raw.top : bars[j].high > raw.top;
      } else {
        inverted = inversionMode === 'close_only' ? bars[j].close < raw.bottom : bars[j].low < raw.bottom;
      }

      if (inverted && inIFVGWindow(j)) {
        var ifvgType = raw.dir === 'bear' ? 'bull' : 'bear';

        var postSweep = false;
        for (var swi = 0; swi < sweeps.length; swi++) {
          var sw = sweeps[swi];
          if (ifvgType === 'bull' && sw.side === 'low'  && sw.bar <= j && sw.bar >= raw.c1 - 20) { postSweep = true; break; }
          if (ifvgType === 'bear' && sw.side === 'high' && sw.bar <= j && sw.bar >= raw.c1 - 20) { postSweep = true; break; }
        }

        ifvgs[fk] = {
          type: ifvgType, startBar: raw.c1, endBar: raw.c3,
          invBar: j, top: raw.top, bottom: raw.bottom, postSweep: postSweep
        };

        if (showEntrySignals && (!onlyPostSweep || postSweep)) {
          var ep = bars[j].close;
          var sBuf = stopBuffer * tickSize;
          var slPrice, tpPrice, risk;

          if (sizingMode === 'fixed_points') {
            // Both SL and TP are explicit point distances from entry.
            if (ifvgType === 'bull') {
              slPrice = ep - slPoints;
              tpPrice = ep + tpPoints;
            } else {
              slPrice = ep + slPoints;
              tpPrice = ep - tpPoints;
            }
            risk = slPoints;
          } else if (sizingMode === 'rr_fixed_sl') {
            // SL is fixed points, TP scales via R:R.
            var rrDist = slPoints * entryRR;
            if (ifvgType === 'bull') {
              slPrice = ep - slPoints;
              tpPrice = ep + rrDist;
            } else {
              slPrice = ep + slPoints;
              tpPrice = ep - rrDist;
            }
            risk = slPoints;
          } else {
            // 'rr_from_ifvg' (default) — SL anchored to FVG boundary + buffer.
            if (ifvgType === 'bull') {
              slPrice = raw.bottom - sBuf;
              risk    = ep - slPrice;
              tpPrice = ep + (risk * entryRR);
            } else {
              slPrice = raw.top + sBuf;
              risk    = slPrice - ep;
              tpPrice = ep - (risk * entryRR);
            }
          }

          entries[fk] = { bar: j, type: ifvgType, price: ep, sl: slPrice, tp: tpPrice, risk: risk };
        }

        break; // first inversion wins for this FVG
      }
    }
  }

  // ╔══════════════════════════════════════════════════════════════╗
  // ║  PASS 5: RETEST + MITIGATION (against in-cycle ifvgs)        ║
  // ║  Stateless — same bars in, same result out.                  ║
  // ╚══════════════════════════════════════════════════════════════╝
  var mitigated = {};
  var retested  = {};
  for (var fk2 in ifvgs) {
    var fvg = ifvgs[fk2];
    var ce = (fvg.top + fvg.bottom) / 2;
    for (var j2 = fvg.invBar + 1; j2 <= confirmed; j2++) {
      if (fvg.type === 'bull') {
        if (!retested[fk2] && bars[j2].low  <= fvg.top && bars[j2].low  >= fvg.bottom) retested[fk2]  = { retestBar: j2 };
        if (bars[j2].close < ce)                                                       { mitigated[fk2] = { mitBar: j2 }; break; }
      } else {
        if (!retested[fk2] && bars[j2].high >= fvg.bottom && bars[j2].high <= fvg.top) retested[fk2]  = { retestBar: j2 };
        if (bars[j2].close > ce)                                                       { mitigated[fk2] = { mitBar: j2 }; break; }
      }
    }
  }

  // ╔══════════════════════════════════════════════════════════════╗
  // ║  PASS 5.5: OUTCOME TRACKING FOR CURRENT ENTRIES              ║
  // ║  For each in-cycle entry, determine SL/TP/open outcome from  ║
  // ║  confirmed bars only. Colors the marker and label.           ║
  // ║  Same-bar tie: SL wins (conservative).                       ║
  // ╚══════════════════════════════════════════════════════════════╝
  var outcomes = {};
  for (var oek in entries) {
    var oent = entries[oek];
    var oResult = 'open';
    var oHitBar = null;
    var oIsBull = oent.type === 'bull';
    for (var jout = oent.bar + 1; jout <= confirmed; jout++) {
      var ob = bars[jout];
      var slHit, tpHit;
      if (oIsBull) {
        slHit = ob.low  <= oent.sl;
        tpHit = ob.high >= oent.tp;
      } else {
        slHit = ob.high >= oent.sl;
        tpHit = ob.low  <= oent.tp;
      }
      if (slHit)      { oResult = 'sl'; oHitBar = jout; break; }
      else if (tpHit) { oResult = 'tp'; oHitBar = jout; break; }
    }
    outcomes[oek] = { result: oResult, hitBar: oHitBar };
  }

  // ╔══════════════════════════════════════════════════════════════╗
  // ║  PASS 6: INTERNAL PIVOTS                                     ║
  // ╚══════════════════════════════════════════════════════════════╝
  var pivotH = {}, pivotL = {}, pivotHSwept = {}, pivotLSwept = {};

  if (showInternalHL) {
    var pStart = cutoffBar + pivotStrength;
    var pEnd   = confirmed - pivotStrength;
    for (var p = pStart; p <= pEnd; p++) {
      var isH = true;
      for (var s = 1; s <= pivotStrength; s++) {
        if (bars[p - s].high >= bars[p].high || bars[p + s].high >= bars[p].high) { isH = false; break; }
      }
      if (isH) pivotH['iH-' + p] = { bar: p, price: bars[p].high };

      var isLo = true;
      for (var s2 = 1; s2 <= pivotStrength; s2++) {
        if (bars[p - s2].low <= bars[p].low || bars[p + s2].low <= bars[p].low) { isLo = false; break; }
      }
      if (isLo) pivotL['iL-' + p] = { bar: p, price: bars[p].low };
    }

    for (var hk in pivotH) {
      var ph = pivotH[hk];
      for (var jp = ph.bar + pivotStrength + 1; jp <= confirmed; jp++) {
        if (bars[jp].high > ph.price) { pivotHSwept[hk] = { sweptBar: jp }; break; }
      }
    }
    for (var lk in pivotL) {
      var pl = pivotL[lk];
      for (var jq = pl.bar + pivotStrength + 1; jq <= confirmed; jq++) {
        if (bars[jq].low < pl.price) { pivotLSwept[lk] = { sweptBar: jq }; break; }
      }
    }
  }

  // ═══════════════════════════════════════════════════════════════════
  // EXECUTION — two-phase order flow
  //
  // Phase A (bracket placement): if we have an open position AND we're
  //   still holding pending SL/TP info from a previous fire, place the
  //   protective orders NOW as separate ctx.strategy.entry() calls.
  //   Runs before the fire gate so the bracket gets down ASAP after fill.
  //
  // Phase B (position reset): if we're back to flat AND bracketPlaced
  //   was true, the position closed (SL or TP hit) — clear both flags
  //   so the next signal can fire cleanly.
  //
  // Phase C (fire on signal): standard canEnter check → iterate entries
  //   → market entry + stash pendingEntry for Phase A on the next cycle.
  // ═══════════════════════════════════════════════════════════════════
  var pos = ctx.strategy.position;

  // ─── Phase A — place SL/TP on newly-filled positions ───
  if (pos && pos.side !== 'flat' && !ctx.state.bracketPlaced && ctx.state.pendingEntry) {
    var pe = ctx.state.pendingEntry;
    var closeSide = pe.side === 'long' ? 'short' : 'long';
    var posQty    = Math.abs(pos.size);

    ctx.strategy.entry('ifvg_sl_' + pe.key, closeSide, {
      type:  'stop',
      price: pe.sl,
      qty:   posQty
    });
    ctx.strategy.entry('ifvg_tp_' + pe.key, closeSide, {
      type:  'limit',
      price: pe.tp,
      qty:   posQty
    });
    ctx.state.bracketPlaced = true;
    ctx.log('Bracket placed for ' + pe.side.toUpperCase() +
            ': SL @ ' + pe.sl.toFixed(2) + ' | TP @ ' + pe.tp.toFixed(2));
  }

  // ─── Phase B — position closed, reset bracket state ───
  if (pos && pos.side === 'flat' && ctx.state.bracketPlaced) {
    ctx.state.bracketPlaced = false;
    ctx.state.pendingEntry  = null;
    ctx.log('Position closed — bracket state reset');
  }

  // ─── Phase C — evaluate whether we can fire a new entry ───
  var canEnter    = true;
  var blockReason = '';

  if (!enableTrading)                                                              { canEnter = false; blockReason = 'Trading disabled'; }
  else if (ctx.state.killSwitchTripped)                                            { canEnter = false; blockReason = 'Kill switch tripped'; }
  else if (pos && pos.side !== 'flat')                                             { canEnter = false; blockReason = 'Position open'; }

  var nowTs = +new Date(bars[last].timestamp);
  var currentSessionKey = sessionKeyFor(confirmed);
  var tradesThisSession = ctx.state.tradesPerSession[currentSessionKey] || 0;

  // Trade-count cap. oneTradePerSession takes precedence (max=1). Otherwise
  // maxTradesPerDay is the ceiling. Counter resets automatically when the
  // session key rolls over at sessionReset time.
  if (canEnter) {
    var effectiveMax = oneTradePerSession ? 1 : maxTradesPerDay;
    if (tradesThisSession >= effectiveMax) {
      canEnter = false;
      blockReason = oneTradePerSession
        ? 'Already traded this session'
        : 'Daily limit hit (' + tradesThisSession + '/' + effectiveMax + ')';
    }
  }

  // Rolling cooldown — independent secondary limiter.
  if (canEnter && cooldownMs > 0 && ctx.state.lastEntryTs > 0) {
    var elapsed = nowTs - ctx.state.lastEntryTs;
    if (elapsed < cooldownMs) {
      canEnter = false;
      var hrsLeft = ((cooldownMs - elapsed) / 3600000).toFixed(1);
      blockReason = 'Cooldown (' + hrsLeft + 'h left)';
    }
  }

  // Daily entry cutoff — blocks NEW entries after this PT time. Position
  // stays open; use flattenAtNYEnd / flattenAtPMEnd if you want it closed.
  if (canEnter && useEntryCutoff) {
    var lastH = ctx.time.hourIn(last, 'America/Los_Angeles');
    var lastM = ctx.time.minuteIn(last, 'America/Los_Angeles');
    var lastT = lastH * 60 + lastM;
    var cutoffT = cutoffHour * 60 + cutoffMin;
    if (lastT >= cutoffT) {
      canEnter = false;
      blockReason = 'Past entry cutoff (' + cutoffHour + ':' + (cutoffMin < 10 ? '0' + cutoffMin : cutoffMin) + ' PT)';
    }
  }

  // Daily profit target — locks in a winning day. Does NOT flatten.
  if (canEnter && dailyProfitTarget > 0 && acct && acct.dayPnl >= dailyProfitTarget) {
    canEnter = false;
    blockReason = 'Profit target hit ($' + acct.dayPnl.toFixed(2) + ')';
  }

  if (canEnter) {
    for (var ek in entries) {
      var e = entries[ek];
      if (e.bar !== confirmed) continue;                    // fire only on the just-confirmed bar
      if (onlyPostSweep && !ifvgs[ek].postSweep) continue;

      // Fire dedup key = timestamp-based so it survives bar-index rotation.
      var fEntryTs = +new Date(bars[e.bar].timestamp);
      var fRaw     = rawFVGs[ek];
      var fC1Ts    = fRaw ? +new Date(bars[fRaw.c1].timestamp) : 0;
      var fKey     = fC1Ts + '_' + (fRaw ? fRaw.dir : e.type) + '_' + fEntryTs;

      if (ctx.state.firedIFVGs[fKey]) continue;             // per-signal dedup

      var orderId = 'ifvg_' + fKey;
      var side = e.type === 'bull' ? 'long' : 'short';

      // Market entry — SDK does NOT accept a nested `bracket` option, so
      // SL and TP are placed as separate orders in Phase A of the NEXT
      // cycle (once the position transitions from flat → filled).
      ctx.strategy.entry(orderId, side, {
        type: 'market',
        qty:  contractQty
      });

      // Stash SL/TP + side for Phase A. bracketPlaced=false ensures the
      // next cycle sees "position open + pending bracket → place it".
      ctx.state.pendingEntry = {
        side: side, sl: e.sl, tp: e.tp, key: fKey
      };
      ctx.state.bracketPlaced = false;

      // Store the entry bar TIMESTAMP so the fired-trade overlay can
      // find its bar index later even after bars rotate.
      ctx.state.firedIFVGs[fKey] = {
        ts: nowTs, side: side, entry: e.price, sl: e.sl, tp: e.tp, risk: e.risk,
        entryTs: fEntryTs
      };
      ctx.state.lastEntryTs         = nowTs;
      ctx.state.lastEntrySessionKey = currentSessionKey;
      ctx.state.tradesPerSession[currentSessionKey] = tradesThisSession + 1;

      var windowLabel = windowFor(e.bar) || 'OFF';
      ctx.log('✅ FIRED ' + side.toUpperCase() + ' @ ' + e.price.toFixed(2) +
              ' SL=' + e.sl.toFixed(2) + ' (' + (e.risk / tickSize).toFixed(0) + 't)' +
              ' TP=' + e.tp.toFixed(2) + ' (' + entryRR + 'R)' +
              ' [' + windowLabel + ']' +
              ' trade ' + (tradesThisSession + 1) + '/' + (oneTradePerSession ? 1 : maxTradesPerDay) +
              ' session=' + currentSessionKey);
      break;
    }
  }

  // ═══════════════════════════════════════════════════════════════════
  // RENDER
  // ═══════════════════════════════════════════════════════════════════

  // ── Session Levels ──
  if (showLevels) {
    for (var ri = 0; ri < sessions.length; ri++) {
      var sess2 = sessions[ri];
      var sk2 = sess2.type + '-' + ri;
      var highSwept = !!sweepSet[sk2 + '-high'];
      ctx.line(sess2.highBar, sess2.high, last, sess2.high, {
        color: highSwept ? bearColor : levelColor, lineWidth: 1,
        lineStyle: highSwept ? 'solid' : 'dashed',
        text: dt(sess2.type) + ' High' + (highSwept ? ' ✓' : '')
      });
      var lowSwept = !!sweepSet[sk2 + '-low'];
      ctx.line(sess2.lowBar, sess2.low, last, sess2.low, {
        color: lowSwept ? bullColor : levelColor, lineWidth: 1,
        lineStyle: lowSwept ? 'solid' : 'dashed',
        text: dt(sess2.type) + ' Low' + (lowSwept ? ' ✓' : '')
      });
    }
  }

  // ── Sweep Arrows ──
  if (showSweepArrows) {
    for (var swi2 = 0; swi2 < sweeps.length; swi2++) {
      var sw2 = sweeps[swi2];
      if (sw2.side === 'low') {
        ctx.shape(sw2.bar, 'arrow_up',   { color: bullColor, location: 'belowBar', text: dt(sw2.sessType) + ' Low Sweep' });
      } else {
        ctx.shape(sw2.bar, 'arrow_down', { color: bearColor, location: 'aboveBar', text: dt(sw2.sessType) + ' High Sweep' });
      }
    }
  }

  // ── IFVG Boxes + CE Lines + Entry Markers (STATELESS — 1:1) ──
  // Everything visual is rendered from the CURRENT-cycle detection.
  // No persistence, no reconciliation, no orphans. If you see a marker,
  // there's a box behind it. If a box mitigates and !showMitigated,
  // BOTH disappear — that's the definition of "current setups only".
  // For historical FIRED trades, flip Show Fired Trade History → ON.
  var renderList = [];
  for (var rk in ifvgs) {
    var isMit = !!mitigated[rk];
    if (!showMitigated && isMit) continue;
    renderList.push({ key: rk, fvg: ifvgs[rk], mitigated: isMit, retested: !!retested[rk] });
  }
  // Insertion sort by startBar. Do NOT convert to Array.prototype.sort
  // with an inline callback — the SDK security validator's case-insensitive
  // regex for the Function constructor also flags lowercase callback
  // expressions and rejects the whole script.
  for (var sortI = 1; sortI < renderList.length; sortI++) {
    var sortKey = renderList[sortI];
    var sortJ = sortI - 1;
    while (sortJ >= 0 && renderList[sortJ].fvg.startBar > sortKey.fvg.startBar) {
      renderList[sortJ + 1] = renderList[sortJ];
      sortJ--;
    }
    renderList[sortJ + 1] = sortKey;
  }
  if (renderList.length > maxIFVGs) renderList = renderList.slice(renderList.length - maxIFVGs);

  var latestActiveIFVG = null;

  for (var di = 0; di < renderList.length; di++) {
    var item = renderList[di];
    var fvg3 = item.fvg;
    var fk3 = item.key;
    var isBull = fvg3.type === 'bull';
    var isMit2 = item.mitigated;
    var isRet = item.retested;
    var color = isMit2 ? mitigatedColor : (isBull ? bullColor : bearColor);
    var gapTicks = ((fvg3.top - fvg3.bottom) / tickSize).toFixed(0);

    // wasFired lookup: firedIFVGs is keyed on timestamps (survives bar
    // rotation). Recompute the same key from the current-cycle box.
    var wfEntry = entries[fk3];
    var wfRaw   = rawFVGs[fk3];
    var wasFired = false;
    if (wfEntry && wfRaw) {
      var wfEntryTs = +new Date(bars[wfEntry.bar].timestamp);
      var wfC1Ts    = +new Date(bars[wfRaw.c1].timestamp);
      wasFired = !!ctx.state.firedIFVGs[wfC1Ts + '_' + wfRaw.dir + '_' + wfEntryTs];
    }

    var label = '';
    if (showLabels) {
      label = 'IFVG ' + (isBull ? '▲' : '▼') + ' ' + gapTicks + 't'
        + (fvg3.postSweep ? ' [SW]' : '') + (wasFired ? ' [FIRED]' : (isRet && !isMit2 ? ' [RET]' : '')) + (isMit2 ? ' [MIT]' : '');
    }

    ctx.box(fvg3.startBar, fvg3.top, fvg3.invBar, fvg3.bottom, {
      fillColor: hexRGBA(color, isMit2 ? 0.08 : 0.2),
      borderColor: color, extend: 'right', text: label
    });

    if (isRet && !isMit2) {
      var rb = retested[fk3].retestBar;
      ctx.shape(rb, isBull ? 'arrow_up' : 'arrow_down', {
        color: color, location: isBull ? 'belowBar' : 'aboveBar', text: 'IFVG Retest'
      });
    }

    if (showCELine && !isMit2) {
      var ceVal = (fvg3.top + fvg3.bottom) / 2;
      ctx.line(fvg3.startBar, ceVal, last, ceVal, {
        color: color, lineWidth: 1, lineStyle: 'dotted', text: 'CE ' + ceVal.toFixed(2)
      });
    }

    // Entry marker — rendered INSIDE the box loop so it's ALWAYS paired
    // with a visible box. When !showMitigated hides a box (via continue
    // above), it also hides this marker. Guaranteed 1:1.
    var entry = entries[fk3];
    if (entry && showEntrySignals && !isMit2) {
      var eOutcome = outcomes[fk3] || { result: 'open', hitBar: null };
      var markerColor, statusTag;
      if (eOutcome.result === 'tp')      { markerColor = bullColor;  statusTag = ' [TP ✓]'; }
      else if (eOutcome.result === 'sl') { markerColor = bearColor;  statusTag = ' [SL ✗]'; }
      else                               { markerColor = entryColor; statusTag = ' [OPEN]'; }

      ctx.shape(entry.bar, 'diamond', {
        color: markerColor,
        location: isBull ? 'belowBar' : 'aboveBar',
        text: (isBull ? 'LONG' : 'SHORT') + ' @ ' + entry.price.toFixed(2) +
              statusTag + (wasFired ? ' [FIRED]' : '')
      });

      if (showEntryLines) {
        var eLineEnd = eOutcome.hitBar
          ? Math.min(eOutcome.hitBar, last)
          : Math.min(entry.bar + 30, last);

        ctx.line(entry.bar, entry.price, eLineEnd, entry.price, {
          color: markerColor, lineWidth: 2, lineStyle: 'solid',
          text: 'Entry ' + entry.price.toFixed(2)
        });
        ctx.line(entry.bar, entry.sl, eLineEnd, entry.sl, {
          color: bearColor, lineWidth: 1,
          lineStyle: eOutcome.result === 'sl' ? 'solid' : 'dashed',
          text: 'SL ' + entry.sl.toFixed(2) + ' (' + (entry.risk / tickSize).toFixed(0) + 't)'
        });
        ctx.line(entry.bar, entry.tp, eLineEnd, entry.tp, {
          color: isBull ? bullColor : bearColor, lineWidth: 1,
          lineStyle: eOutcome.result === 'tp' ? 'solid' : 'dashed',
          text: 'TP ' + entry.tp.toFixed(2) + (sizingMode === 'fixed_points' ? ' (' + tpPoints + 'p)' : ' (' + entryRR + 'R)')
        });

        if (eOutcome.hitBar !== null) {
          ctx.shape(eOutcome.hitBar,
            eOutcome.result === 'tp' ? (isBull ? 'arrow_up' : 'arrow_down') : 'xcross',
            {
              color: markerColor,
              location: eOutcome.result === 'tp' ? (isBull ? 'aboveBar' : 'belowBar')
                                                 : (isBull ? 'belowBar' : 'aboveBar'),
              text: eOutcome.result === 'tp' ? 'TP HIT' : 'SL HIT'
            });
        }
      }
    }

    if (!isMit2) latestActiveIFVG = { key: fk3, fvg: fvg3 };
  }

  // ═══════════════════════════════════════════════════════════════════
  // FIRED TRADE HISTORY OVERLAY (opt-in via Show Fired Trade History)
  // Only renders markers for trades that ACTUALLY FIRED live orders —
  // pulled from ctx.state.firedIFVGs. Uses CIRCLES (not diamonds) so
  // there's no visual confusion with current-setup entries. Every
  // fired trade has its entry timestamp stored, so we convert back to
  // a bar index and render the marker + outcome arrow.
  // ═══════════════════════════════════════════════════════════════════
  if (showFiredHistory) {
    var histTsToIdx = {};
    for (var hmi = 0; hmi <= last; hmi++) {
      histTsToIdx[+new Date(bars[hmi].timestamp)] = hmi;
    }
    for (var histKey in ctx.state.firedIFVGs) {
      var histRec = ctx.state.firedIFVGs[histKey];
      if (!histRec || histRec.entryTs === undefined) continue; // old-schema record
      var histBar = histTsToIdx[histRec.entryTs];
      if (histBar === undefined) continue; // scrolled out
      var histIsLong = histRec.side === 'long';

      // Outcome from bars — same logic as PASS 5.5.
      var histResult = 'open', histHitBar = null;
      for (var hjo = histBar + 1; hjo <= confirmed; hjo++) {
        var hob = bars[hjo];
        var hSlHit, hTpHit;
        if (histIsLong) { hSlHit = hob.low  <= histRec.sl; hTpHit = hob.high >= histRec.tp; }
        else            { hSlHit = hob.high >= histRec.sl; hTpHit = hob.low  <= histRec.tp; }
        if (hSlHit)      { histResult = 'sl'; histHitBar = hjo; break; }
        else if (hTpHit) { histResult = 'tp'; histHitBar = hjo; break; }
      }

      var histColor = histResult === 'tp' ? bullColor
                    : histResult === 'sl' ? bearColor
                    : entryColor;

      ctx.shape(histBar, 'circle', {
        color: histColor,
        location: histIsLong ? 'belowBar' : 'aboveBar',
        text: 'FIRED ' + (histIsLong ? 'LONG' : 'SHORT') + ' @ ' + histRec.entry.toFixed(2) +
              (histResult === 'tp' ? ' [TP ✓]' : histResult === 'sl' ? ' [SL ✗]' : ' [OPEN]')
      });

      if (histHitBar !== null) {
        ctx.shape(histHitBar,
          histResult === 'tp' ? (histIsLong ? 'arrow_up' : 'arrow_down') : 'xcross',
          {
            color: histColor,
            location: histResult === 'tp' ? (histIsLong ? 'aboveBar' : 'belowBar')
                                          : (histIsLong ? 'belowBar' : 'aboveBar'),
            text: histResult === 'tp' ? 'TP HIT' : 'SL HIT'
          });
      }
    }
  }

  // ── Internal Pivot Lines ──
  if (showInternalHL) {
    for (var hk2 in pivotH) {
      var ph2 = pivotH[hk2];
      var swept = pivotHSwept[hk2];
      var drawEnd = swept ? swept.sweptBar : last;
      var col = swept ? bearColor : internalColor;
      ctx.line(ph2.bar, ph2.price, drawEnd, ph2.price, {
        color: col, lineWidth: 1, lineStyle: swept ? 'solid' : 'dashed',
        text: 'iH ' + ph2.price.toFixed(2) + (swept ? ' ✗' : '')
      });
    }
    for (var lk2 in pivotL) {
      var pl2 = pivotL[lk2];
      var swept2 = pivotLSwept[lk2];
      var drawEnd2 = swept2 ? swept2.sweptBar : last;
      var col2 = swept2 ? bullColor : internalColor;
      ctx.line(pl2.bar, pl2.price, drawEnd2, pl2.price, {
        color: col2, lineWidth: 1, lineStyle: swept2 ? 'solid' : 'dashed',
        text: 'iL ' + pl2.price.toFixed(2) + (swept2 ? ' ✗' : '')
      });
    }
  }

  // ═══════════════════════════════════════════════════════════════════
  // STATUS TABLE
  // ═══════════════════════════════════════════════════════════════════
  if (!showTable) return;

  // ── HTF Bias ──
  var htf = ctx.request(biasHTF);
  var htfBias = 0;
  if (htf && htf.length >= 2) {
    var htfBar = htf[htf.length - 2];
    if (htfBar) {
      if (htfBar.close > htfBar.open) htfBias = 1;
      else if (htfBar.close < htfBar.open) htfBias = -1;
    }
  }

  // ── Delta Bias ──
  var delta = ctx.footprint && ctx.footprint.delta;
  var cumDelta = 0, deltaBias = 0;
  if (delta) {
    var dStart = Math.max(0, last - biasDeltaLen);
    for (var d = dStart; d <= last; d++) { if (delta[d] !== null) cumDelta += delta[d]; }
    if (cumDelta > 0) deltaBias = 1; else if (cumDelta < 0) deltaBias = -1;
  }

  // ── EMA Bias ──
  var ema9 = ctx.ta.ema(ctx.price.close, 9);
  var ema21 = ctx.ta.ema(ctx.price.close, 21);
  var emaBias = 0;
  if (ema9[last] !== null && ema21[last] !== null) {
    if (ema9[last] > ema21[last]) emaBias = 1;
    else if (ema9[last] < ema21[last]) emaBias = -1;
  }

  // ── RSI Bias ──
  var rsi = ctx.ta.rsi(ctx.price.close, biasRSILen);
  var rsiBias = 0;
  if (rsi[last] !== null) {
    if (rsi[last] > 55) rsiBias = 1;
    else if (rsi[last] < 45) rsiBias = -1;
  }

  // ── POC Bias ──
  var poc = ctx.footprint && ctx.footprint.poc;
  var pocBias = 0;
  if (poc && poc[last] !== null) {
    if (bars[last].close > poc[last]) pocBias = 1;
    else if (bars[last].close < poc[last]) pocBias = -1;
  }

  var sweepCount = sweeps.length;

  // ── Composite Bias ──
  var biasScore = htfBias + deltaBias + emaBias + rsiBias + pocBias;
  var bias = 'Neutral', biasColor2 = neutralColor;
  if (biasScore >= 3) { bias = 'Bullish'; biasColor2 = bullColor; }
  else if (biasScore <= -3) { bias = 'Bearish'; biasColor2 = bearColor; }

  // ── Trade Status ──
  var tradeStatus = enableTrading ? 'LIVE' : 'DISABLED';
  var tradeColor  = enableTrading ? (ctx.state.killSwitchTripped ? bearColor : bullColor) : '#888';
  if (ctx.state.killSwitchTripped) tradeStatus = 'KILL SWITCH';

  // ── Position ── (pos already declared in Phase A above)
  var posText = 'Flat', posColor = '#888';
  if (pos && pos.side !== 'flat') {
    posText = pos.side.toUpperCase() + ' ' + pos.size + ' @ ' + pos.avgPrice.toFixed(2);
    posColor = pos.side === 'long' ? bullColor : bearColor;
  }

  // ── Day P&L ──
  var pnlText = '—', pnlColor = '#888';
  if (acct) {
    pnlText = '$' + acct.dayPnl.toFixed(2);
    pnlColor = acct.dayPnl >= 0 ? bullColor : bearColor;
  }

  // ── IFVG Status ──
  var ifvgStatus = 'None', ifvgStatusColor = '#888';
  if (latestActiveIFVG) {
    var isRetested = !!retested[latestActiveIFVG.key];
    ifvgStatus = isRetested ? 'Retested ✓' : (latestActiveIFVG.fvg.postSweep ? 'Active [Post-Sweep]' : 'Active');
    ifvgStatusColor = latestActiveIFVG.fvg.type === 'bull' ? bullColor : bearColor;
  }

  var ifvgDir = '—', ifvgDirColor = '#888';
  if (latestActiveIFVG) {
    ifvgDir = latestActiveIFVG.fvg.type === 'bull' ? 'Bullish ▲' : 'Bearish ▼';
    ifvgDirColor = latestActiveIFVG.fvg.type === 'bull' ? bullColor : bearColor;
  }

  // ── Entry ──
  var entryText = '—', entryTextColor = '#888', slText = '—', tpText = '—';
  if (latestActiveIFVG) {
    var latestEntry = entries[latestActiveIFVG.key];
    if (latestEntry) {
      var isLong2 = latestEntry.type === 'bull';
      entryText = (isLong2 ? 'LONG' : 'SHORT') + ' @ ' + latestEntry.price.toFixed(2);
      entryTextColor = isLong2 ? bullColor : bearColor;
      slText = latestEntry.sl.toFixed(2) + ' (' + (latestEntry.risk / tickSize).toFixed(0) + 't)';
      // TP label depends on sizing mode — RR only makes sense for the
      // two RR-based modes; fixed_points shows the raw point distance.
      if (sizingMode === 'fixed_points') {
        tpText = latestEntry.tp.toFixed(2) + ' (' + tpPoints + 'p)';
      } else {
        tpText = latestEntry.tp.toFixed(2) + ' (' + entryRR + 'R)';
      }
    }
  }

  // ── Sizing mode label ──
  var sizingText = sizingMode === 'fixed_points' ? 'Fixed ' + slPoints + 'p SL / ' + tpPoints + 'p TP'
                 : sizingMode === 'rr_fixed_sl'  ? 'Fixed ' + slPoints + 'p SL / ' + entryRR + 'R TP'
                 :                                  'IFVG SL / ' + entryRR + 'R TP';

  // ── Trade-outcome tally (visible history — never repaints) ──
  var wins = 0, losses = 0, open = 0;
  for (var otk in outcomes) {
    var otRes = outcomes[otk].result;
    if      (otRes === 'tp')   wins++;
    else if (otRes === 'sl')   losses++;
    else                       open++;
  }
  var winsColor   = wins > 0   ? bullColor : '#888';
  var lossesColor = losses > 0 ? bearColor : '#888';
  var openColor   = open > 0   ? entryColor : '#888';

  // ── Live-session info for table ──
  var currentWindow = windowFor(last);
  var windowText = currentWindow ? currentWindow + ' Session' : 'Outside Window';
  var windowColor = currentWindow === 'AM' ? bullColor : currentWindow === 'PM' ? '#00e5ff' : '#888';

  var tradeCap = oneTradePerSession ? 1 : maxTradesPerDay;
  var tradesText, tradesColor;
  if (currentSessionKey === null) {
    tradesText  = '—';
    tradesColor = '#888';
  } else {
    var winTag  = currentWindow ? ' ' + currentWindow : '';
    tradesText  = tradesThisSession + ' / ' + tradeCap + winTag;
    tradesColor = tradesThisSession >= tradeCap ? bearColor : (tradesThisSession > 0 ? bullColor : '#aaa');
  }

  // ── Render Table ──
  var t = ctx.table('top_right', 2, 16, {
    bgcolor: 'rgba(13,17,28,0.92)', borderColor: 'rgba(255,255,255,0.08)',
    fontSize: 11, cellPadding: 6, paddingTop: 80
  });

  ctx.cell(t, 0, 0, 'IFVG AUTO', { textColor: '#fff', bgcolor: 'rgba(255,255,255,0.06)', fontSize: 12 });
  ctx.cell(t, 0, 1, 'v11',       { textColor: '#fff', bgcolor: 'rgba(255,255,255,0.06)', fontSize: 12 });

  ctx.cell(t, 1, 0, 'Trading',   { textColor: '#aaa' });
  ctx.cell(t, 1, 1, tradeStatus, { textColor: tradeColor });

  ctx.cell(t, 2, 0, 'Window',    { textColor: '#aaa' });
  ctx.cell(t, 2, 1, windowText,  { textColor: windowColor });

  ctx.cell(t, 3, 0, 'Trades',    { textColor: '#aaa' });
  ctx.cell(t, 3, 1, tradesText,  { textColor: tradesColor });

  ctx.cell(t, 4, 0, 'Sizing',    { textColor: '#aaa' });
  ctx.cell(t, 4, 1, sizingText,  { textColor: entryColor });

  ctx.cell(t, 5, 0, 'W / L / Open', { textColor: '#aaa' });
  ctx.cell(t, 5, 1,
    (wins > 0 ? wins + 'W ' : '0W ') +
    (losses > 0 ? losses + 'L ' : '0L ') +
    (open > 0 ? open + 'O' : '0O'),
    { textColor: wins > losses ? bullColor : losses > wins ? bearColor : '#aaa' });

  ctx.cell(t, 6, 0, 'Position',  { textColor: '#aaa' });
  ctx.cell(t, 6, 1, posText,     { textColor: posColor });

  ctx.cell(t, 7, 0, 'Day P&L',   { textColor: '#aaa' });
  ctx.cell(t, 7, 1, pnlText,     { textColor: pnlColor });

  ctx.cell(t, 8, 0, 'Sweeps',    { textColor: '#aaa' });
  ctx.cell(t, 8, 1, sweepCount > 0 ? 'Yes (' + sweepCount + ')' : 'No', { textColor: sweepCount > 0 ? bullColor : '#888' });

  ctx.cell(t, 9, 0, 'Bias',      { textColor: '#aaa' });
  ctx.cell(t, 9, 1, bias,        { textColor: biasColor2 });

  ctx.cell(t, 10, 0, 'IFVG',      { textColor: '#aaa' });
  ctx.cell(t, 10, 1, ifvgStatus,  { textColor: ifvgStatusColor });

  ctx.cell(t, 11, 0, 'Direction', { textColor: '#aaa' });
  ctx.cell(t, 11, 1, ifvgDir,     { textColor: ifvgDirColor });

  ctx.cell(t, 12, 0, '── ENTRY ──', { textColor: entryColor, bgcolor: 'rgba(255,255,255,0.04)', fontSize: 11 });
  ctx.cell(t, 12, 1, entryText,   { textColor: entryTextColor, bgcolor: 'rgba(255,255,255,0.04)', fontSize: 11 });

  ctx.cell(t, 13, 0, 'Stop Loss', { textColor: '#aaa' });
  ctx.cell(t, 13, 1, slText,      { textColor: bearColor });

  ctx.cell(t, 14, 0, 'Take Profit', { textColor: '#aaa' });
  ctx.cell(t, 14, 1, tpText,        { textColor: bullColor });

  ctx.cell(t, 15, 0, 'Block',       { textColor: '#aaa' });
  ctx.cell(t, 15, 1, blockReason || (canEnter ? 'Ready' : '—'), { textColor: canEnter ? bullColor : '#888' });
}