Anchored VWAP
Weekly and daily VWAP that reset on real UTC session boundaries, with a shaded band and a stretch readout for mean-reversion context.
VWAP is the volume-weighted average price, the level large players benchmark fills against. A plain cumulative VWAP drifts: it averages from wherever the chart happened to start loading, so the line you see depends on how much history your browser fetched. This recipe pins VWAP to calendar sessions instead. The weekly line resets every Monday at 00:00 UTC and the daily line resets every midnight, so two traders looking at the same symbol see the same level. It adds a percentage band and a "stretch" number that tells you how far price has pulled from fair value.
The wrun indicator
param("band_pct", 0.5, { min: 0.1, max: 3, description: "Band distance from the weekly VWAP, in percent" });
output("week_vwap", line, overlay, { color: "#f5a623", width: 2, description: "VWAP anchored to the UTC week" });
output("day_vwap", line, overlay, { color: "#4a90d9", width: 1, description: "VWAP anchored to the UTC day" });
const bandTop = output("week_upper", line, overlay, { color: "#f5a623", opacity: 0.3 });
const bandBottom = output("week_lower", line, overlay, { color: "#f5a623", opacity: 0.3 });
box("week_band", { top: bandTop, bottom: bandBottom, color: "#f5a623", opacity: 0.08, borderWidth: 0 });
output("stretch_pct", line, lower, { unit: "%", color: "#f5a623", description: "Close distance from the weekly VWAP" });
let bandPct: f64 = 0.5;
let weekIdx: f64 = NaN;
let weekPv: f64 = 0.0;
let weekVol: f64 = 0.0;
let weekComplete: bool = false;
let dayIdx: f64 = NaN;
let dayPv: f64 = 0.0;
let dayVol: f64 = 0.0;
let dayComplete: bool = false;
function onStart(): void {
bandPct = p_band_pct();
}
function onBar(): void {
const close = bar.close();
const typical = (bar.high() + bar.low() + close) / 3.0;
const volume = bar.volume();
// UTC day index of this bar; epoch day 0 was a Thursday, so +3 makes weeks start on Monday 00:00 UTC.
const day = Math.floor(bar.time() / 86400.0);
const week = Math.floor((day + 3.0) / 7.0);
if (week != weekIdx) {
if (!isNaN(weekIdx)) weekComplete = true;
weekIdx = week;
weekPv = 0.0;
weekVol = 0.0;
}
if (day != dayIdx) {
if (!isNaN(dayIdx)) dayComplete = true;
dayIdx = day;
dayPv = 0.0;
dayVol = 0.0;
}
weekPv += typical * volume;
weekVol += volume;
dayPv += typical * volume;
dayVol += volume;
// A session that was already running when the history starts is incomplete: stay NaN until the first boundary.
const weekVwap = weekComplete && weekVol > 0.0 ? weekPv / weekVol : NaN;
const dayVwap = dayComplete && dayVol > 0.0 ? dayPv / dayVol : NaN;
out_week_vwap(weekVwap);
out_day_vwap(dayVwap);
out_week_upper(weekVwap * (1.0 + bandPct / 100.0));
out_week_lower(weekVwap * (1.0 - bandPct / 100.0));
out_stretch_pct(isNaN(weekVwap) || weekVwap <= 0.0 ? NaN : ((close - weekVwap) / weekVwap) * 100.0);
}How it works
The anchor is the whole trick. bar.time() hands the module each bar's open time in epoch seconds. Divide by 86400 and you have a UTC day index; shift by three days and divide by seven and you have a week index whose boundary is Monday 00:00 UTC. When the index changes, the price-times-volume and volume sums reset. The reset is tied to the calendar, not to your scroll position or how much history loaded, which is exactly why the level is stable and shared. It is eight lines of arithmetic you can read.
Warm-up is honest. A session that was already running when the loaded history starts is incomplete, so both lines stay NaN until their first boundary passes. The daily line fills in within the first day of loaded data, the weekly line needs about a week. onBar() still writes every output on those bars: the row exists, and the lines that are ready draw while the ones that are warming do not. That is the warm-up rule from the Execution model: an output written NaN draws nothing on that bar, and the others draw as usual.
The band frames the move. week_upper and week_lower are the weekly VWAP scaled by a percentage, drawn faint with opacity: 0.3, and box("week_band", ...) shades between them: with from and to left at 0 every bar contributes a one-bar-wide slice and the slices tile into a channel. Its borderWidth: 0 keeps the slices from showing seams. The two band outputs are bound to consts because the box names them by handle. When price rides the upper band it is stretched rich versus the week's fair value; when it sags to the lower band it is cheap.
The stretch readout. (close - week_vwap) / week_vwap * 100 is the most actionable number, so it gets its own pane as stretch_pct with a % unit, NaN while the weekly line is warming. The indicator has it on every bar: a series you can read back in the legend, and a value an alert from the chart can compare.
Design notes
- There is no anchored-VWAP builtin:
bar.time()and a reset-on-boundary sum are the building blocks, and they work for any anchor. coloris declared on each output, and the band tint is a box color with its ownopacity. There is no color picker param: aparam(...)declares a number, with amin, amaxand adescription(Styling).- The band is a box on every bar, one bar wide, tiling into a channel.
range("week_upper", "week_lower", { ... })is the other form: the chart draws it as a filled band between the two outputs, with a colour ladder or a gradient when you want one (Styling). - The stretch readout is a lower-pane series: visible on every bar, and a value an alert from the chart can follow. A one-cell table is possible with a string slot and
render.table(Drawing objects), at the cost of switching the file to the second runtime contract.
Customize it
- Band width.
band_pctsets how far the band sits from the VWAP. Tighten it toward0.2on calm majors, widen it toward2on volatile names so the band actually contains the noise. - Anchor period. For a monthly anchor, compute the month index from the day index (a lookup over cumulative month lengths, leap years included); the reset logic does not change. Monthly needs about a month of loaded history before it shows anything.
- Band the daily line too. Duplicate the two band outputs and the box against
day_vwapif you trade the intraday session instead. - Stretch as a signal. Add a data-only gate output (
stretch_pct > 1.5) and ashapeoutput withshape_whereon it, the way the volume spike marks its bars. Or skip the mark: publish the indicator, add it to a chart, and set an alert from the chart onstretch_pctwith Greater than at 1.5; Once only fires it a single time (Alerts).
Run it
- In the editor's Explorer, press New indicator and pick Blank indicator: the tab holds the
//@lang=wrun-tsline alone. - Paste the indicator block above under that line and press Run. The daily line fills in within the first loaded day, the weekly line within the first loaded week.
- At the editor's Console prompt, type
last 20 stretch_pctto read the stretch of the last 20 bars.
Concepts used
- Time and sessions for the day and week indexes from
bar.time() - Volume and VWAP for the anchored-VWAP pattern as a worked function
- Drawing objects for the per-bar box that shades the band
- Missing values (NaN) for the
NaNwarm-up on each line