IFVG - DodgyDD and ICT

Description

the famous ifvg, to detect sweeps of key session levels, with lookback periods, plus more. made free for the community. free for community use, to build off of.

Categories & Tags

Smart Money#ict#ifvg

Comments (0)

0/2000

Loading comments…

Source code

// ICT IFVG Sweep Strategy — SXVNT SDK (v8)
//
// ZERO-REPAINT / ZERO-SHIFT ARCHITECTURE:
// - ALL detection uses ONLY confirmed (closed) bars — never the live bar
// - Recalculated from scratch each call using CURRENT bar indices
// - No bar indices stored in ctx.state → immune to bar array rotation
// - Confirmed bar data never changes → same bars always produce same signals
// - Multi-pass: FVGs detected first, then inversions (chain filter is accurate)

function calculate(bars, ctx) {
  // ═══ 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 showMitigated       = ctx.input('Show Mitigated IFVGs', true);
  var showCELine          = ctx.input('Display Consequent Encroachment', true);
  var maxIFVGs            = ctx.input('Max IFVGs Displayed', 10, { min: 1, max: 50, step: 1 });

  var showEntrySignals = ctx.input('Show Entry Signals', true);
  var onlyPostSweep    = ctx.input('Entry Only After Sweep', true);
  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');

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

  var showInternalHL = ctx.input('Show Internal H/L', true);
  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);

  var tickSize      = 0.25;
  var minSweepPrice = minSweepTicks * tickSize;
  var minGapPrice   = minGapTicks * tickSize;
  var last          = bars.length - 1;
  var confirmed     = last - 1; // last CLOSED bar — live bar is NEVER used for detection

  if (confirmed < 2) return;

  // ═══ 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; }

  // ╔══════════════════════════════════════════════════════════════╗
  // ║  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 (confirmed bars only)              ║
  // ╚══════════════════════════════════════════════════════════════╝

  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 (confirmed bars only)               ║
  // ╚══════════════════════════════════════════════════════════════╝

  var sweeps = [];       // array of { bar, side, sessType, level, sessIdx }
  var sweepSet = {};     // quick lookup: 'Type-endBar-side' → true

  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 FVG DETECTION (confirmed bars only)             ║
  // ║  Detect ALL raw FVGs first so chain filter is accurate       ║
  // ╚══════════════════════════════════════════════════════════════╝

  var rawFVGs = {};  // key: 'dir-c1' → { dir, c1, c3, top, bottom }

  for (var i = cutoffBar + 2; i <= confirmed; i++) {
    var c1 = i - 2;

    // Bearish FVG: gap between c1.low and c3.high (price dropped through gap)
    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 };
    }

    // Bullish FVG: gap between c3.low and c1.high (price rose through gap)
    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 DETECTION (confirmed bars only)   ║
  // ║  Chain filter uses complete rawFVGs from Pass 3               ║
  // ╚══════════════════════════════════════════════════════════════╝

  var ifvgs = {};    // key → { type, startBar, endBar, invBar, top, bottom, postSweep }
  var entries = {};  // key → { bar, type, price, sl, tp, risk }

  for (var fk in rawFVGs) {
    var raw = rawFVGs[fk];

    // Chain filter: skip if a same-direction FVG exists at c1+1 or c1+2
    // Only the LAST in a consecutive chain should invert
    var prefix = raw.dir === 'bear' ? 'be-' : 'bu-';
    if (rawFVGs[prefix + (raw.c1 + 1)] || rawFVGs[prefix + (raw.c1 + 2)]) continue;

    // Scan the inversion window: bars from c3+1 to c3+maxBarsForInversion
    var scanEnd = Math.min(raw.c3 + maxBarsForInversion, confirmed);
    for (var j = raw.c3 + 1; j <= scanEnd; j++) {
      // Check inversion
      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';

        // Check if any sweep preceded this inversion
        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; }
        }

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

        // Lock entry (uses confirmed bar j's close — frozen value)
        if (showEntrySignals && (!onlyPostSweep || postSweep)) {
          var ep = bars[j].close;
          var sBuf = stopBuffer * tickSize;
          if (ifvgType === 'bull') {
            var sl = raw.bottom - sBuf;
            var rk = ep - sl;
            entries[fk] = { bar: j, type: 'bull', price: ep, sl: sl, tp: ep + (rk * entryRR), risk: rk };
          } else {
            var sl2 = raw.top + sBuf;
            var rk2 = sl2 - ep;
            entries[fk] = { bar: j, type: 'bear', price: ep, sl: sl2, tp: ep - (rk2 * entryRR), risk: rk2 };
          }
        }

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

  // ╔══════════════════════════════════════════════════════════════╗
  // ║  PASS 5: RETEST + MITIGATION (confirmed bars only)           ║
  // ╚══════════════════════════════════════════════════════════════╝

  var mitigated = {};  // key → { mitBar }
  var retested = {};   // key → { retestBar }

  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 (!retested[fk2] && bars[j].low <= fvg.top && bars[j].low >= fvg.bottom) {
          retested[fk2] = { retestBar: j };
        }
        if (bars[j].close < ce) {
          mitigated[fk2] = { mitBar: j };
          break; // once mitigated, stop scanning
        }
      } else {
        if (!retested[fk2] && bars[j].high >= fvg.bottom && bars[j].high <= fvg.top) {
          retested[fk2] = { retestBar: j };
        }
        if (bars[j].close > ce) {
          mitigated[fk2] = { mitBar: j };
          break;
        }
      }
    }
  }

  // ╔══════════════════════════════════════════════════════════════╗
  // ║  PASS 6: INTERNAL PIVOTS (confirmed bars only)               ║
  // ╚══════════════════════════════════════════════════════════════╝

  var pivotH = {};       // 'iH-bar' → { bar, price }
  var pivotL = {};       // 'iL-bar' → { bar, price }
  var pivotHSwept = {};  // 'iH-bar' → { sweptBar }
  var pivotLSwept = {};  // 'iL-bar' → { sweptBar }

  if (showInternalHL) {
    // Detect pivots (need pivotStrength bars on each side)
    var pStart = cutoffBar + pivotStrength;
    var pEnd   = confirmed - pivotStrength;
    for (var p = pStart; p <= pEnd; p++) {
      // Swing high
      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 };

      // Swing low
      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 };
    }

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

  // ╔══════════════════════════════════════════════════════════════╗
  // ║  RENDER — Uses fresh indices computed above                  ║
  // ║  No stale state, no shifting — all indices are current       ║
  // ╚══════════════════════════════════════════════════════════════╝

  // ── Session Levels ──
  if (showLevels) {
    for (var ri = 0; ri < sessions.length; ri++) {
      var sess = sessions[ri];
      var sk = sess.type + '-' + ri;

      var highSwept = !!sweepSet[sk + '-high'];
      ctx.line(sess.highBar, sess.high, last, sess.high, {
        color: highSwept ? bearColor : levelColor, lineWidth: 1,
        lineStyle: highSwept ? 'solid' : 'dashed',
        text: dt(sess.type) + ' High' + (highSwept ? ' \u2713' : '')
      });

      var lowSwept = !!sweepSet[sk + '-low'];
      ctx.line(sess.lowBar, sess.low, last, sess.low, {
        color: lowSwept ? bullColor : levelColor, lineWidth: 1,
        lineStyle: lowSwept ? 'solid' : 'dashed',
        text: dt(sess.type) + ' Low' + (lowSwept ? ' \u2713' : '')
      });
    }
  }

  // ── 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 Signals ──
  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] });
  }
  renderList.sort(function(a, b) { return a.fvg.startBar - b.fvg.startBar; });
  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);

    // Label
    var label = '';
    if (showLabels) {
      label = 'IFVG ' + (isBull ? '\u25B2' : '\u25BC') + ' ' + gapTicks + 't'
        + (fvg3.postSweep ? ' [SW]' : '') + (isRet && !isMit2 ? ' [RET]' : '') + (isMit2 ? ' [MIT]' : '');
    }

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

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

    // CE line
    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 signal
    var entry = entries[fk3];
    if (entry && showEntrySignals && !isMit2) {
      var isLong = entry.type === 'bull';
      ctx.shape(entry.bar, 'diamond', {
        color: entryColor, location: isLong ? 'belowBar' : 'aboveBar',
        text: (isLong ? '\uD83D\uDD35 LONG' : '\uD83D\uDD34 SHORT') + ' @ ' + entry.price.toFixed(2)
      });

      if (showEntryLines) {
        var lineEnd = Math.min(entry.bar + 30, last);
        ctx.line(entry.bar, entry.price, lineEnd, entry.price, {
          color: entryColor, lineWidth: 2, lineStyle: 'solid',
          text: 'Entry ' + entry.price.toFixed(2)
        });
        ctx.line(entry.bar, entry.sl, lineEnd, entry.sl, {
          color: bearColor, lineWidth: 1, lineStyle: 'dashed',
          text: 'SL ' + entry.sl.toFixed(2) + ' (' + (entry.risk / tickSize).toFixed(0) + 't)'
        });
        ctx.line(entry.bar, entry.tp, lineEnd, entry.tp, {
          color: isLong ? bullColor : bearColor, lineWidth: 1, lineStyle: 'dashed',
          text: 'TP ' + entry.tp.toFixed(2) + ' (' + entryRR + 'R)'
        });
      }
    }

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

  // ── 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 ? ' \u2717' : '')
      });
    }
    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 ? ' \u2717' : '')
      });
    }
  }

  // ╔══════════════════════════════════════════════════════════════╗
  // ║  STATUS TABLE                                                ║
  // ║  Bias uses live data — that's correct (bias is a live        ║
  // ║  indicator, not a frozen signal)                              ║
  // ╚══════════════════════════════════════════════════════════════╝

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

  // ── Sweep Count ──
  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; }

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

  var ifvgDir = '\u2014', ifvgDirColor = '#888';
  if (latestActiveIFVG) {
    ifvgDir = latestActiveIFVG.fvg.type === 'bull' ? 'Bullish \u25B2' : 'Bearish \u25BC';
    ifvgDirColor = latestActiveIFVG.fvg.type === 'bull' ? bullColor : bearColor;
  }

  // ── Target ──
  var targetText = '\u2014', targetColor = '#888';
  var currentPrice = bars[last].close;
  if (bias === 'Bullish') {
    var nd = Infinity;
    for (var ti = 0; ti < sessions.length; ti++) {
      var tSess = sessions[ti];
      var tsk = tSess.type + '-' + ti;
      if (!sweepSet[tsk + '-high'] && tSess.high > currentPrice) {
        var tDist = tSess.high - currentPrice;
        if (tDist < nd) { nd = tDist; targetText = dt(tSess.type) + ' High (' + tSess.high.toFixed(2) + ')'; targetColor = bullColor; }
      }
    }
  } else if (bias === 'Bearish') {
    var nd2 = Infinity;
    for (var ti2 = 0; ti2 < sessions.length; ti2++) {
      var tSess2 = sessions[ti2];
      var tsk2 = tSess2.type + '-' + ti2;
      if (!sweepSet[tsk2 + '-low'] && tSess2.low < currentPrice) {
        var tDist2 = currentPrice - tSess2.low;
        if (tDist2 < nd2) { nd2 = tDist2; targetText = dt(tSess2.type) + ' Low (' + tSess2.low.toFixed(2) + ')'; targetColor = bearColor; }
      }
    }
  }

  // ── Breakeven ──
  var beText = '\u2014', beColor = '#888';
  if (latestActiveIFVG && showInternalHL) {
    var beFlagged = false;
    if (latestActiveIFVG.fvg.type === 'bull') {
      for (var blk in pivotL) {
        var bpl = pivotL[blk];
        if (bpl.bar > latestActiveIFVG.fvg.invBar && pivotLSwept[blk]) { beFlagged = true; break; }
      }
    } else {
      for (var bhk in pivotH) {
        var bph = pivotH[bhk];
        if (bph.bar > latestActiveIFVG.fvg.invBar && pivotHSwept[bhk]) { beFlagged = true; break; }
      }
    }
    if (beFlagged) { beText = 'Move to BE \u26A0'; beColor = '#ff9800'; }
    else { beText = 'Hold'; beColor = latestActiveIFVG.fvg.type === 'bull' ? bullColor : bearColor; }
  }

  // ── Entry ──
  var entryText = '\u2014', entryTextColor = '#888', slText = '\u2014', tpText = '\u2014';
  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)';
      tpText = latestEntry.tp.toFixed(2) + ' (' + entryRR + 'R)';
    }
  }

  // ── Render Table ──
  var t = ctx.table('top_right', 2, 11, {
    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 SWEEP', { textColor: '#fff', bgcolor: 'rgba(255,255,255,0.06)', fontSize: 12 });
  ctx.cell(t, 0, 1, 'STATUS', { textColor: '#fff', bgcolor: 'rgba(255,255,255,0.06)', fontSize: 12 });

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

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

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

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

  ctx.cell(t, 5, 0, 'Post-Sweep', { textColor: '#aaa' });
  var psText = latestActiveIFVG ? (latestActiveIFVG.fvg.postSweep ? 'Yes \u2713' : 'No') : '\u2014';
  var psColor = latestActiveIFVG && latestActiveIFVG.fvg.postSweep ? bullColor : '#888';
  ctx.cell(t, 5, 1, psText, { textColor: psColor });

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

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

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

  ctx.cell(t, 9, 0, 'Breakeven', { textColor: '#aaa' });
  ctx.cell(t, 9, 1, beText, { textColor: beColor });

  ctx.cell(t, 10, 0, 'Target', { textColor: '#aaa' });
  ctx.cell(t, 10, 1, targetText, { textColor: targetColor });
}