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