VWAP w/ stdev

Description

VWAP with Standard Deviation Channels Volume-weighted average price with up to 5 fully configurable standard deviation bands. Built native for SXVNT — so the levels you see line up with what you're used to. Features Anchored or Rolling VWAP in one indicator Length = 0 → traditional session-cumulative VWAP (resets daily) Length = 9, 21, 50, etc. → rolling N-bar moving VWAP — works like an EMA but volume-weighted Up to 5 standard deviation channels — set Number of Channels to 0 (just VWAP line), 1, 2, 3, 4, or 5. Each band has its own σ multiplier so you can run equal spacing (1, 2, 3, 4, 5) or asymmetric (0.5, 1, 1.5, 2, 3). Per-band color control — every band has independent top color, bottom color, and fill color. Build your own gradient (default goes blue → indigo → violet → magenta → pink for cool outward fade) or color-code by significance. Session reset that actually matches CME futures — uses America/New_York time, defaults to 18:00 ET (Globex daily open). Adjust the reset hour for equities (9), 24h crypto (0), or whatever your market's session boundary is. DST is handled automatically. Five anchor modes — Session / Week / Month / Year / Continuous Four price sources — HLC3 (default), Close, HL2, OHLC4 Adjustable line widths and fill opacity — outer bands auto-fade so 5-channel mode stays readable Math Real volume-weighted variance: Mean = Σ(price × volume) / Σ(volume) Variance = Σ(price² × volume) / Σ(volume) − mean² How to use it: Add to chart Open Script Settings Set Length to 0 for daily session VWAP or 9/21 for a rolling MVWAP Choose how many channels you want Customize colors per band V2 - fix to vwap anchored going flat.

Categories & Tags

Comments (0)

0/2000

Loading comments…

Source code

// VWAP with Standard Deviation Channels
//
// Volume-weighted average price plus up to 5 configurable standard-
// deviation bands. Supports both Anchored (session-cumulative) and
// Rolling (N-bar window) modes — same script works as a standard
// daily VWAP or a 9/21-bar moving VWAP.
//
// Each band has independent +σ color, -σ color, and a fill color.
// Fills shade between adjacent bands (VWAP → ±σ1 → ±σ2 …).
//
// Math:
//   Anchored: cumulative volume-weighted mean from the anchor bar
//   Rolling : same formulas but sums slide over the last N bars
//   VW mean    = Σ(price × volume) / Σ(volume)
//   VW variance = Σ(p² × v) / Σ(v) − mean²
//   Matches TradingView's session-VWAP + stddev bands exactly.
//
// Robustness:
//   - Session detection is ET-timezone aware (CME futures session
//     resets at 18:00 ET, not UTC midnight). Keeps the line aligned
//     with the symbol's actual trading day on NQ/ES/CL/etc.
//   - Zero-volume bars don't freeze the line. If no volume is
//     available, falls back to a uniform-weighted average so the
//     line keeps tracking price even on low-liquidity periods.

function calculate(bars, ctx) {
  // ─── CORE INPUTS ─────────────────────────────────────────────────
  const source = ctx.input('Source', 'hlc3', {
    options: ['hlc3', 'close', 'hl2', 'ohlc4']
  });

  // 0 → Anchored (cumulative from session start)
  // > 0 → Rolling N-bar VWAP (e.g. 9, 21, 50)
  const length = ctx.input('Length (0 = anchored, >0 = rolling)', 0, {
    min: 0, max: 500, step: 1
  });

  const anchor = ctx.input('Anchor (when length=0)', 'Session', {
    options: ['Session', 'Week', 'Month', 'Year', 'Continuous']
  });

  // "Futures" session = ET-day starting 18:00 ET (CME convention).
  // "RTH" / "ET Day" = ET-day starting 00:00 ET (calendar midnight).
  // "UTC Day" = legacy UTC midnight (kept for backwards compat).
  const sessionMode = ctx.input('Session Boundary', 'Futures (18:00 ET)', {
    options: ['Futures (18:00 ET)', 'ET Day (00:00 ET)', 'UTC Day']
  });

  // ─── BAND COUNT (0 = none, just VWAP line) ───────────────────────
  const numChannels = ctx.input('Number of Channels', 3, {
    min: 0, max: 5, step: 1
  });

  // ─── PER-BAND CONFIG ─────────────────────────────────────────────
  const m1 = ctx.input('Band 1 — StDev', 1.0, { min: 0, max: 20, step: 0.1 });
  const c1Up = ctx.input('Band 1 — Top Color',    '#42A5F5');
  const c1Lo = ctx.input('Band 1 — Bottom Color', '#42A5F5');
  const f1   = ctx.input('Band 1 — Fill Color',   '#42A5F5');

  const m2 = ctx.input('Band 2 — StDev', 2.0, { min: 0, max: 20, step: 0.1 });
  const c2Up = ctx.input('Band 2 — Top Color',    '#5C6BC0');
  const c2Lo = ctx.input('Band 2 — Bottom Color', '#5C6BC0');
  const f2   = ctx.input('Band 2 — Fill Color',   '#5C6BC0');

  const m3 = ctx.input('Band 3 — StDev', 3.0, { min: 0, max: 20, step: 0.1 });
  const c3Up = ctx.input('Band 3 — Top Color',    '#7E57C2');
  const c3Lo = ctx.input('Band 3 — Bottom Color', '#7E57C2');
  const f3   = ctx.input('Band 3 — Fill Color',   '#7E57C2');

  const m4 = ctx.input('Band 4 — StDev', 4.0, { min: 0, max: 20, step: 0.1 });
  const c4Up = ctx.input('Band 4 — Top Color',    '#AB47BC');
  const c4Lo = ctx.input('Band 4 — Bottom Color', '#AB47BC');
  const f4   = ctx.input('Band 4 — Fill Color',   '#AB47BC');

  const m5 = ctx.input('Band 5 — StDev', 5.0, { min: 0, max: 20, step: 0.1 });
  const c5Up = ctx.input('Band 5 — Top Color',    '#EC407A');
  const c5Lo = ctx.input('Band 5 — Bottom Color', '#EC407A');
  const f5   = ctx.input('Band 5 — Fill Color',   '#EC407A');

  // ─── STYLING ─────────────────────────────────────────────────────
  const vwapColor   = ctx.input('VWAP Color',  '#FFEB3B');
  const vwapWidth   = ctx.input('VWAP Width',  2, { min: 1, max: 5, step: 1 });
  const bandWidth   = ctx.input('Band Width',  1, { min: 1, max: 5, step: 1 });
  const showFills   = ctx.input('Show Fills',  true);
  const fillOpacity = ctx.input('Fill Opacity', 0.06, { min: 0, max: 1, step: 0.01 });

  // ─── SOURCE PRICE ARRAY ──────────────────────────────────────────
  const srcMap = {
    hlc3:  ctx.price.hlc3,
    close: ctx.price.close,
    hl2:   ctx.price.hl2,
    ohlc4: ctx.price.ohlc4,
  };
  const src = srcMap[source] || ctx.price.hlc3;
  const volume = ctx.price.volume;
  const n = bars.length;

  // ─── ET-AWARE SESSION KEY ────────────────────────────────────────
  // Compute a per-bar "session key" string. Reset detection just
  // compares consecutive bars' keys — when they differ, that's a
  // session boundary. Avoids the UTC/ET timezone math drift the old
  // getUTCDate()-based check had on US futures.
  function sessionKey(ts) {
    if (sessionMode === 'UTC Day') {
      const d = new Date(ts);
      return d.getUTCFullYear() + '-' + (d.getUTCMonth() + 1) + '-' + d.getUTCDate();
    }
    // Both 'Futures (18:00 ET)' and 'ET Day (00:00 ET)' read the
    // wall-clock in America/New_York.
    const parts = new Intl.DateTimeFormat('en-US', {
      timeZone: 'America/New_York',
      year: 'numeric', month: '2-digit', day: '2-digit',
      hour: '2-digit', hourCycle: 'h23',
    }).formatToParts(new Date(ts));
    let y = 0, m = 0, d = 0, h = 0;
    for (const p of parts) {
      if (p.type === 'year')  y = parseInt(p.value, 10);
      if (p.type === 'month') m = parseInt(p.value, 10);
      if (p.type === 'day')   d = parseInt(p.value, 10);
      if (p.type === 'hour')  h = parseInt(p.value, 10);
    }
    if (sessionMode === 'Futures (18:00 ET)' && h >= 18) {
      // 18:00 ET onward = next trading day's session.
      const tomorrow = new Date(Date.UTC(y, m - 1, d + 1));
      return tomorrow.getUTCFullYear() + '-' + (tomorrow.getUTCMonth() + 1) + '-' + tomorrow.getUTCDate();
    }
    return y + '-' + m + '-' + d;
  }

  // Returns true at the FIRST bar of each new session frame.
  function isResetBar(i) {
    if (i === 0) return true;
    if (anchor === 'Continuous') return false;
    if (anchor === 'Session') {
      return sessionKey(bars[i - 1].timestamp) !== sessionKey(bars[i].timestamp);
    }
    const a = new Date(bars[i - 1].timestamp);
    const b = new Date(bars[i].timestamp);
    if (anchor === 'Week') {
      return b.getUTCDay() < a.getUTCDay();
    }
    if (anchor === 'Month') {
      return a.getUTCMonth() !== b.getUTCMonth()
          || a.getUTCFullYear() !== b.getUTCFullYear();
    }
    if (anchor === 'Year') {
      return a.getUTCFullYear() !== b.getUTCFullYear();
    }
    return false;
  }

  // ─── OUTPUT ARRAYS ───────────────────────────────────────────────
  const vwap  = new Array(n).fill(null);
  const upper = [new Array(n).fill(null), new Array(n).fill(null), new Array(n).fill(null), new Array(n).fill(null), new Array(n).fill(null)];
  const lower = [new Array(n).fill(null), new Array(n).fill(null), new Array(n).fill(null), new Array(n).fill(null), new Array(n).fill(null)];
  const mults     = [m1, m2, m3, m4, m5];
  const upColors  = [c1Up, c2Up, c3Up, c4Up, c5Up];
  const loColors  = [c1Lo, c2Lo, c3Lo, c4Lo, c5Lo];
  const fillColors = [f1, f2, f3, f4, f5];

  // ─── BAR WEIGHT ─────────────────────────────────────────────────
  // Use real volume when available; fall back to 1 so zero-volume
  // bars still nudge the running mean (otherwise the VWAP freezes
  // horizontally on any stretch of bars with v === 0). Price has to
  // be finite-and-positive to count — guards against NaN/0 ticks.
  function barWeight(p, v) {
    if (!isFinite(p) || p <= 0) return 0;
    if (isFinite(v) && v > 0) return v;
    return 1; // uniform-weight fallback
  }

  // ─── COMPUTE ────────────────────────────────────────────────────
  if (length > 0) {
    // ─── ROLLING N-BAR VWAP ────────────────────────────────────────
    for (let i = 0; i < n; i++) {
      const start = Math.max(0, i - length + 1);
      let sumPV = 0, sumV = 0, sumP2V = 0;
      for (let j = start; j <= i; j++) {
        const p = src[j];
        const w = barWeight(p, volume[j]);
        if (w > 0) {
          sumPV  += p * w;
          sumV   += w;
          sumP2V += p * p * w;
        }
      }
      if (sumV > 0) {
        const mean = sumPV / sumV;
        const variance = Math.max(0, (sumP2V / sumV) - mean * mean);
        const stdev = Math.sqrt(variance);
        vwap[i] = mean;
        for (let k = 0; k < numChannels; k++) {
          upper[k][i] = mean + mults[k] * stdev;
          lower[k][i] = mean - mults[k] * stdev;
        }
      }
    }
  } else {
    // ─── ANCHORED CUMULATIVE VWAP ─────────────────────────────────
    let sumPV = 0, sumV = 0, sumP2V = 0;
    for (let i = 0; i < n; i++) {
      if (isResetBar(i)) {
        sumPV = 0;
        sumV  = 0;
        sumP2V = 0;
        // Skip plotting on the reset bar so the previous session's
        // line doesn't slope into the new session's converged-at-
        // zero-stddev point. The new session starts rendering at
        // bar i+1 once we have at least one bar of weighted price.
        continue;
      }
      const p = src[i];
      const w = barWeight(p, volume[i]);
      if (w > 0) {
        sumPV  += p * w;
        sumV   += w;
        sumP2V += p * p * w;
      }
      if (sumV > 0) {
        const mean = sumPV / sumV;
        const variance = Math.max(0, (sumP2V / sumV) - mean * mean);
        const stdev = Math.sqrt(variance);
        vwap[i] = mean;
        for (let k = 0; k < numChannels; k++) {
          upper[k][i] = mean + mults[k] * stdev;
          lower[k][i] = mean - mults[k] * stdev;
        }
      }
    }
  }

  // ─── PLOTTING ───────────────────────────────────────────────────
  const vwapId = ctx.plot(vwap, 'VWAP', {
    color: vwapColor,
    lineWidth: vwapWidth,
  });

  // Convert "#RRGGBB" + alpha to "rgba(r,g,b,a)" for fills.
  function withAlpha(hex, alpha) {
    const h = hex.replace('#', '');
    const r = parseInt(h.slice(0, 2), 16);
    const g = parseInt(h.slice(2, 4), 16);
    const b = parseInt(h.slice(4, 6), 16);
    return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
  }

  let prevUpperId = vwapId;
  let prevLowerId = vwapId;

  for (let k = 0; k < numChannels; k++) {
    const sigma = mults[k];
    const upperId = ctx.plot(upper[k], '+' + sigma + 'σ', {
      color: upColors[k],
      lineWidth: bandWidth,
    });
    const lowerId = ctx.plot(lower[k], '-' + sigma + 'σ', {
      color: loColors[k],
      lineWidth: bandWidth,
    });

    if (showFills) {
      // Each band uses its own fill color. Outer rings fade slightly
      // so the chart doesn't get muddy when all 5 channels are on.
      const layerAlpha = Math.max(0.01, fillOpacity * (1 - k * 0.15));
      const fill = withAlpha(fillColors[k], layerAlpha);
      ctx.fill(prevUpperId, upperId, { colorUp: fill, colorDown: fill });
      ctx.fill(prevLowerId, lowerId, { colorUp: fill, colorDown: fill });
    }

    prevUpperId = upperId;
    prevLowerId = lowerId;
  }
}