Description

for everyone, ifvg just automated.

Comments (0)

0/2000

Loading comments…

Source code

// ═══════════════════════════════════════════════════════════════════
// ICT IFVG Sweep Strategy — AUTOMATED (v10)
// ═══════════════════════════════════════════════════════════════════
// EVERY setting below is a user input. NOTHING is hardcoded. Whatever
// you configure in the Strategy Settings panel is what the strategy
// uses — both in live and in backtest.
//
// DEFAULTS (match your stated spec):
//   - 50 point Stop Loss
//   - 25 point Take Profit
//   - 1 trade per session, session resets at 6:30 AM PT
//   - Enable Live Trading defaults OFF (use the toggle; honored live,
//     bypassed in backtest via liveOnly so backtests always produce
//     trades regardless of the toggle).
// ═══════════════════════════════════════════════════════════════════

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 });

  // Risk in POINTS. Both SL and TP are absolute distances from entry.
  var slPoints       = ctx.input('Stop Loss (points)', 50, { min: 1, max: 500, step: 1 });
  var tpPoints       = ctx.input('Take Profit (points)', 25, { min: 1, max: 500, step: 1 });

  // ─── Trade frequency ───
  // "1 trade per session" means: after a fire, no more trades until the
  // NEXT session reset. The reset happens at sessionResetHour:Min in PT.
  var oneTradePerSession = ctx.input('One Trade Per Session', true);
  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 });

  // Additional rolling cooldown (independent of session). 0 = off.
  // Use this if you also want a minimum-time-between-trades safety net.
  var cooldownHrs    = ctx.input('Rolling Cooldown Hours (0 = off)', 0, { min: 0, max: 48, step: 1 });

  // ─── Live safety ───
  var dailyLossKill  = ctx.input('Daily Loss Kill ($, 0 = off)', 0, { min: 0, max: 10000, step: 50 });
  var flattenAtNYEnd = ctx.input('Flatten at NY End', false);

  // ═══ DETECTION INPUTS ═══
  var lookbackDays        = ctx.input('Lookback Days', 1, { 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', 'wick_or_close', { options: ['close_only', 'wick_or_close'] });
  var onlyPostSweep       = ctx.input('Entry Only After Sweep', true);

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

  // ═══ VISUAL INPUTS ═══
  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 levelColor     = ctx.input('Session Level Color', '#ffeb3b');
  var entryColor     = ctx.input('Entry Signal Color', '#00e5ff');
  var showLevels     = ctx.input('Show Session Levels', true);
  var showLabels     = ctx.input('Show IFVG Labels', true);
  var showCELine     = ctx.input('Display Consequent Encroachment', true);
  var showMitigated  = ctx.input('Show Mitigated IFVGs', true);
  var maxIFVGs       = ctx.input('Max IFVGs Displayed', 10, { min: 1, max: 50, step: 1 });
  var showTable      = ctx.input('Show Status Table', true);

  // ═══ CONSTANTS ═══
  var 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;

  if (confirmed < 2) return;

  // ═══ STATE INIT ═══
  if (!ctx.state.firedIFVGs)             ctx.state.firedIFVGs = {};
  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;

  // ═══ 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;
  }
  function inIFVGWindow(barIndex) {
    if (!useIFVGTimeFilter) return true;
    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;
    if (s < e) return t >= s && t < e;
    return t >= s || t < e;
  }
  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 ★
  // A "session" runs from sessionResetHour:Min PT one day to the same
  // time the next day. Bars before today's reset belong to YESTERDAY's
  // session; bars after today's reset belong to TODAY's session. Returns
  // a stable string used to identify which session a bar belongs to so
  // we can prevent multiple entries within the same session.
  function sessionKeyFor(barIndex) {
    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;
    // Subtract elapsed-time-today (in local PT) from the bar's epoch ts to
    // estimate local PT midnight. If the bar is before today's session
    // reset, this session belongs to YESTERDAY → subtract another day.
    var localMidnightMs = ts - localMin * 60 * 1000;
    if (localMin < resetMin) localMidnightMs -= 24 * 60 * 60 * 1000;
    // Quantize to day resolution. (DST shifts the boundary by 1h twice a
    // year — acceptable for a per-day dedup key.)
    return String(Math.floor(localMidnightMs / 86400000));
  }

  // ═══ KILL SWITCH ═══
  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.side !== 'flat') {
    var lastHour = ctx.time.hourIn(last, 'America/New_York');
    if (lastHour >= nyEnd) {
      ctx.log('NY 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: SESSIONS ═══
  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: SWEEPS ═══
  var sweeps = [];
  var sweepSet = {};
  for (var si = 0; si < sessions.length; si++) {
    var sess = sessions[si];
    for (var i = sess.endBar + 1; i <= confirmed; i++) {
      var sk = sess.type + '-' + si;
      if (!sweepSet[sk + '-low']  && bars[i].low  < sess.low  - minSweepPrice) {
        sweepSet[sk + '-low']  = true;
        sweeps.push({ bar: i, side: 'low',  sessType: sess.type, level: sess.low,  sessIdx: si });
      }
      if (!sweepSet[sk + '-high'] && bars[i].high > sess.high + minSweepPrice) {
        sweepSet[sk + '-high'] = true;
        sweeps.push({ bar: i, side: 'high', sessType: sess.type, level: sess.high, sessIdx: si });
      }
    }
  }

  // ═══ PASS 3: RAW FVGs ═══
  var rawFVGs = {};
  for (var i = cutoffBar + 2; i <= confirmed; i++) {
    var c1 = i - 2;
    var beTop = bars[c1].low;
    var beBot = bars[i].high;
    if (beTop - beBot >= minGapPrice) {
      rawFVGs['be-' + c1] = { dir: 'bear', c1: c1, c3: i, top: beTop, bottom: beBot };
    }
    var buTop = bars[i].low;
    var buBot = bars[c1].high;
    if (buTop - buBot >= minGapPrice) {
      rawFVGs['bu-' + c1] = { dir: 'bull', c1: c1, c3: i, top: buTop, bottom: buBot };
    }
  }

  // ═══ PASS 4: INVERSION + ENTRY (uses configured points for SL/TP) ═══
  var ifvgs = {};
  var entries = {};
  for (var fk in rawFVGs) {
    var raw = rawFVGs[fk];
    var prefix = raw.dir === 'bear' ? 'be-' : 'bu-';
    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 (!onlyPostSweep || postSweep) {
          var ep = bars[j].close;
          // ★ SL/TP from CONFIGURED points (50/25 by default).
          // Fixed price distances, NOT FVG geometry.
          if (ifvgType === 'bull') {
            entries[fk] = {
              bar: j, type: 'bull', price: ep,
              sl: ep - slPoints, tp: ep + tpPoints,
              ts: +new Date(bars[j].timestamp)
            };
          } else {
            entries[fk] = {
              bar: j, type: 'bear', price: ep,
              sl: ep + slPoints, tp: ep - tpPoints,
              ts: +new Date(bars[j].timestamp)
            };
          }
        }
        break;
      }
    }
  }

  // ═══ PASS 5: MITIGATION (display only) ═══
  var mitigated = {};
  for (var fk2 in ifvgs) {
    var fvg = ifvgs[fk2];
    var ce = (fvg.top + fvg.bottom) / 2;
    for (var j = fvg.invBar + 1; j <= confirmed; j++) {
      if (fvg.type === 'bull') {
        if (bars[j].close < ce) { mitigated[fk2] = { mitBar: j }; break; }
      } else {
        if (bars[j].close > ce) { mitigated[fk2] = { mitBar: j }; break; }
      }
    }
  }

  // ═══ EXECUTION ═══
  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 (ctx.strategy.position.side !== 'flat') { canEnter = false; blockReason = 'Position open'; }

  var nowTs = +new Date(bars[last].timestamp);

  // Session-based dedup — the primary rate limiter
  var currentSessionKey = sessionKeyFor(confirmed);
  if (canEnter && oneTradePerSession && ctx.state.lastEntrySessionKey === currentSessionKey) {
    canEnter = false;
    blockReason = 'Already traded this session';
  }

  // Rolling cooldown — independent secondary limiter (0 = off)
  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)';
    }
  }

  if (canEnter) {
    for (var ek in entries) {
      var e = entries[ek];
      // Only fire on the CONFIRMED bar (signal just locked in)
      if (e.bar !== confirmed) continue;
      // Optional sweep filter
      if (onlyPostSweep && !ifvgs[ek].postSweep) continue;
      // Dedup — same IFVG can't fire twice
      if (ctx.state.firedIFVGs[ek]) continue;

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

      ctx.strategy.entry(orderId, side, {
        qty: contractQty,
        bracket: {
          stopLoss:   { price: e.sl },
          takeProfit: [{ price: e.tp, qty: contractQty }]
        }
      });

      ctx.state.firedIFVGs[ek]         = { ts: nowTs, side: side, entry: e.price, sl: e.sl, tp: e.tp };
      ctx.state.lastEntryTs            = nowTs;
      ctx.state.lastEntrySessionKey    = currentSessionKey;

      ctx.log('✅ FIRED ' + side.toUpperCase() + ' @ ' + e.price.toFixed(2) +
              ' SL=' + e.sl.toFixed(2) + ' (' + slPoints + 'p)' +
              ' TP=' + e.tp.toFixed(2) + ' (' + tpPoints + 'p)' +
              ' session=' + currentSessionKey);
      break;
    }
  }

  // ═══ RENDER ═══
  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 ? ' ✓' : '')
      });
    }
  }

  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' });
    }
  }

  var renderList = [];
  for (var rk in ifvgs) {
    var isMit = !!mitigated[rk];
    if (!showMitigated && isMit) continue;
    renderList.push({ key: rk, fvg: ifvgs[rk], mitigated: isMit });
  }
  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 color = isMit2 ? mitigatedColor : (isBull ? bullColor : bearColor);
    var gapTicks = ((fvg3.top - fvg3.bottom) / tickSize).toFixed(0);
    var wasFired = !!ctx.state.firedIFVGs[fk3];

    var label = '';
    if (showLabels) {
      label = 'IFVG ' + (isBull ? '▲' : '▼') + ' ' + gapTicks + 't'
        + (fvg3.postSweep ? ' [SW]' : '') + (wasFired ? ' [FIRED]' : '') + (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 (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)
      });
    }
    var entry = entries[fk3];
    if (entry && !isMit2) {
      var isLong = entry.type === 'bull';
      ctx.shape(entry.bar, 'diamond', {
        color: entryColor, location: isLong ? 'belowBar' : 'aboveBar',
        text: (isLong ? 'LONG' : 'SHORT') + ' @ ' + entry.price.toFixed(2)
      });
    }
    if (!isMit2) latestActiveIFVG = { key: fk3, fvg: fvg3 };
  }

  if (!showTable) return;

  // Status table
  var pos = ctx.strategy.position;
  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;
  }
  var pnlText = '—', pnlColor = '#888';
  if (acct) {
    pnlText = '$' + acct.dayPnl.toFixed(2);
    pnlColor = acct.dayPnl >= 0 ? bullColor : bearColor;
  }
  var tradeStatus = enableTrading ? 'LIVE' : 'DISABLED';
  var tradeColor = enableTrading ? (ctx.state.killSwitchTripped ? bearColor : bullColor) : '#888';
  if (ctx.state.killSwitchTripped) tradeStatus = 'KILLED';

  var sessionText = oneTradePerSession
    ? (ctx.state.lastEntrySessionKey === currentSessionKey ? 'Used' : 'Available')
    : 'off';
  var sessionColor = (oneTradePerSession && ctx.state.lastEntrySessionKey === currentSessionKey) ? bearColor : bullColor;

  var t = ctx.table('top_right', 2, 8, {
    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 v10 AUTO', { textColor: '#fff', bgcolor: 'rgba(255,255,255,0.06)', fontSize: 12 });
  ctx.cell(t, 0, 1, tradeStatus, { textColor: tradeColor, bgcolor: 'rgba(255,255,255,0.06)', fontSize: 12 });
  ctx.cell(t, 1, 0, 'Position', { textColor: '#aaa' });
  ctx.cell(t, 1, 1, posText, { textColor: posColor });
  ctx.cell(t, 2, 0, 'Day P&L', { textColor: '#aaa' });
  ctx.cell(t, 2, 1, pnlText, { textColor: pnlColor });
  ctx.cell(t, 3, 0, 'Session Slot', { textColor: '#aaa' });
  ctx.cell(t, 3, 1, sessionText, { textColor: sessionColor });
  ctx.cell(t, 4, 0, 'SL / TP', { textColor: '#aaa' });
  ctx.cell(t, 4, 1, slPoints + 'p / ' + tpPoints + 'p', { textColor: '#fff' });
  ctx.cell(t, 5, 0, 'Active IFVG', { textColor: '#aaa' });
  var ifvgStatus = '—', ifvgStatusColor = '#888';
  if (latestActiveIFVG) {
    var isBullIF = latestActiveIFVG.fvg.type === 'bull';
    ifvgStatus = (isBullIF ? '▲ Bull' : '▼ Bear') + (latestActiveIFVG.fvg.postSweep ? ' [SW]' : ' [no sweep]');
    ifvgStatusColor = isBullIF ? bullColor : bearColor;
  }
  ctx.cell(t, 5, 1, ifvgStatus, { textColor: ifvgStatusColor });
  ctx.cell(t, 6, 0, 'Trades Fired', { textColor: '#aaa' });
  var firedCount = Object.keys(ctx.state.firedIFVGs).length;
  ctx.cell(t, 6, 1, String(firedCount), { textColor: firedCount > 0 ? entryColor : '#888' });
  ctx.cell(t, 7, 0, 'Block', { textColor: '#aaa' });
  ctx.cell(t, 7, 1, blockReason || 'None', { textColor: blockReason ? bearColor : '#888' });
}