Build a VWAP Mean Reversion Strategy in Pine Script v6
By HorizonAI Team · 12 min read · Intermediate
Price rips 1.8% above VWAP by 10:15, you short the extension, and it keeps going. Twenty minutes later you're stopped out, and the reversion you were waiting for finally happens without you. That isn't a VWAP problem. It's a missing-rules problem: no defined stretch threshold, no session boundary, no regime check, no exit target.
This is the build that fixes all four, as one complete Pine Script v6 strategy you can paste into TradingView today.
The short answer
A VWAP mean reversion strategy fades price back toward the volume-weighted average price after it stretches a set number of standard deviations away from it. In Pine Script v6 the whole band model is one function: ta.vwap(source, anchor, stdev_mult) returns VWAP plus its upper and lower bands as a tuple. You go long when a bar closes below the lower band, short when it closes above the upper band, and target VWAP itself.
The complete version needs six pieces, and skipping any one of them is where most builds fall apart:
- A session-anchored VWAP, reset at each new trading day, not a rolling average.
- Standard-deviation bands at a fixed multiple (2.0 SD is the working default) to define "stretched."
- An entry trigger that requires a reversal candle, not just a band touch.
- A target at VWAP, updated every bar because VWAP moves.
- An ATR-based stop, fixed at entry, so a trend day costs you one unit instead of ten.
- A regime filter and a session window, because mean reversion fails in strong trends and VWAP is meaningless overnight.
What you need before you start
- A TradingView account on any plan that gives you intraday bars. The strategy is built for 1-minute to 15-minute charts; 5-minute is the sweet spot for equities and index futures.
- An instrument with real volume. VWAP is volume-weighted, so it's honest on stocks, futures, and crypto, and much less meaningful on spot forex pairs where your broker's "volume" is tick count.
- Enough Pine to read a
strategy()declaration and anifblock. If that's new, the beginner Pine Script walkthrough covers the syntax this article assumes.
Why VWAP anchors intraday mean reversion
VWAP is the average price of every share traded today, weighted by size. That makes it the closest thing intraday markets have to a published fair value, and it's the number institutional desks are measured against. A fund filling 400,000 shares over a session is judged on whether it beat VWAP, so when price runs well above it, the buyers still working an order have every reason to wait, and the sellers have every reason to hit it.
That's the mechanical reason price keeps returning to VWAP during balanced sessions. It isn't magic, and it isn't a law. It's a crowd of size-constrained participants all referencing the same line.
Two consequences fall straight out of that, and both shape the code:
- VWAP resets. It's cumulative from the session open, so it means nothing across days. Anchoring it correctly is the single most important line in the script.
- It only works while the session is balanced. On a gap-and-go trend day, price rides one band the whole morning and every fade is a loss. That's what the regime filter is for.
If you want the wider family of setups this belongs to, from RSI bounces to band fades across timeframes, the mean reversion strategies guide maps them out. Here we're building one specific intraday variant, end to end.
Building the bands: two ways to measure the stretch
"Stretched" needs a number. Standard deviation gives you one that adapts to the day's own volatility, so a 2 SD extension on a quiet Tuesday is a much smaller dollar move than 2 SD on CPI morning. That's the behavior you want.
The built-in: ta.vwap with a standard-deviation multiplier
Pine's ta.vwap has an overload that does the entire job. Pass it a source, an anchor condition, and a multiplier, and it hands back three series: the VWAP, the upper band, and the lower band (Pine Script v6 reference).
//@version=6
indicator("VWAP + Standard Deviation Bands", overlay = true)
sdMult = input.float(2.0, "Band multiplier (SD)", minval = 0.5, maxval = 4.0, step = 0.1)
srcVwap = input.source(hlc3, "VWAP source")
// Reset the cumulative calculation at the start of every trading day
newDay = timeframe.change("D")
[vwapValue, upperBand, lowerBand] = ta.vwap(srcVwap, newDay, sdMult)
plot(vwapValue, "VWAP", color = color.new(color.orange, 0), linewidth = 2)
pu = plot(upperBand, "Upper band", color = color.new(color.red, 30))
pl = plot(lowerBand, "Lower band", color = color.new(color.teal, 30))
fill(pu, pl, color = color.new(color.gray, 94), title = "VWAP envelope")
timeframe.change("D") is true on the first bar of each new daily period, which is exactly the anchor you want. The deviation here is volume-weighted around VWAP, not a plain price deviation, so the bands widen when heavy volume trades away from the average. That's the correct behavior and it's why this beats hand-rolling it.
The manual alternative: rolling standard deviation
Some traders prefer bands built from a fixed lookback, which makes them react faster in the afternoon when the cumulative calculation has gone stiff. ta.stdev on the distance between price and VWAP gets you there:
length = input.int(100, "Rolling length", minval = 10)
vwapMid = ta.vwap(hlc3, timeframe.change("D"))
dev = ta.stdev(close - vwapMid, length)
upperRoll = vwapMid + dev * sdMult
lowerRoll = vwapMid - dev * sdMult
Neither is wrong. The built-in respects the session's real volume distribution; the rolling version is more responsive late in the day. Build with the built-in first, then swap in these four lines and re-test if your afternoon trades feel like they're triggering too late.
Step by step: turning bands into a strategy
Five rules take a chart overlay and make it something the Strategy Tester can price.
1. Anchor the session, then wait out the open. VWAP in the first few bars is computed from almost no data, so the bands are absurdly tight and every bar looks extended. Count bars since the day's first bar and refuse entries until you have at least six of them, which is 30 minutes on a 5-minute chart.
2. Trade only inside your window. Entries between 09:30 and 15:45 New York, nothing after. TradingView's time() function returns na outside a session string, so not na(time(...)) is your in-session flag (sessions documentation).
3. Require a reversal, not a touch. A close beyond the band tells you price is stretched. It doesn't tell you it's finished. Adding one condition, that the bar closes above its open for a long, kills a large share of the "caught the falling knife" entries.
4. Target VWAP, and update the order every bar. This is the detail people miss. If you set the limit once at entry, you're targeting where VWAP was. Re-issuing strategy.exit with the same ID on every bar modifies the live order so the target tracks the line.
5. Stop on ATR, captured at entry. Take ATR(14) at the moment you enter, store it, and place the stop 1.5x that distance from your fill. If you recompute ATR every bar the stop drifts, which is not what a stop is for. ATR sizing adapts the distance to the instrument so the same script works on ES and on a $12 stock.
The complete VWAP mean reversion strategy
Here's the whole thing. Paste it into the Pine Editor, save, and add it to a 5-minute chart.
//@version=6
strategy("VWAP Mean Reversion (Intraday)",
shorttitle = "VWAP MR",
overlay = true,
initial_capital = 10000,
default_qty_type = strategy.percent_of_equity,
default_qty_value = 10,
commission_type = strategy.commission.percent,
commission_value = 0.02,
slippage = 2,
process_orders_on_close = true)
// ---------------- Inputs ----------------
gBands = "VWAP bands"
sdEntry = input.float(2.0, "Entry band (SD from VWAP)", minval = 0.5, maxval = 4.0, step = 0.1, group = gBands)
srcVwap = input.source(hlc3, "VWAP source", group = gBands)
gSess = "Session"
sessTime = input.session("0930-1545", "Entry window", group = gSess)
flatTime = input.session("1550-1600", "Force-flat window", group = gSess)
sessTz = input.string("America/New_York", "Timezone", group = gSess)
warmupLen = input.int(6, "Warm-up bars after the open", minval = 0, group = gSess)
gRisk = "Risk"
atrLen = input.int(14, "ATR length", minval = 1, group = gRisk)
stopAtr = input.float(1.5, "Stop distance (x ATR)", minval = 0.5, step = 0.1, group = gRisk)
gFilt = "Regime filter"
useTrendFilter = input.bool(true, "Skip strong trends", group = gFilt)
adxLen = input.int(14, "ADX length", minval = 1, group = gFilt)
adxMax = input.int(25, "Max ADX for entries", minval = 5, group = gFilt)
// ---------------- VWAP and bands ----------------
newDay = timeframe.change("D")
[vwapValue, upperBand, lowerBand] = ta.vwap(srcVwap, newDay, sdEntry)
// ---------------- Session handling ----------------
inWindow = not na(time(timeframe.period, sessTime, sessTz))
inFlat = not na(time(timeframe.period, flatTime, sessTz))
var int barsToday = 0
barsToday := newDay ? 1 : barsToday + 1
warmedUp = barsToday > warmupLen
// ---------------- Regime filter ----------------
[diPlus, diMinus, adxValue] = ta.dmi(adxLen, adxLen)
regimeOk = not useTrendFilter or adxValue < adxMax
// ---------------- Risk ----------------
atrValue = ta.atr(atrLen)
var float entryAtr = na
// ---------------- Entry conditions ----------------
tradeable = inWindow and not inFlat and warmedUp and regimeOk
longSignal = tradeable and close < lowerBand and close > open
shortSignal = tradeable and close > upperBand and close < open
if longSignal and strategy.position_size == 0
entryAtr := atrValue
strategy.entry("Long", strategy.long)
if shortSignal and strategy.position_size == 0
entryAtr := atrValue
strategy.entry("Short", strategy.short)
// ---------------- Exits: target tracks VWAP, stop is fixed at entry ----------------
longStop = strategy.position_avg_price - entryAtr * stopAtr
shortStop = strategy.position_avg_price + entryAtr * stopAtr
if strategy.position_size > 0
strategy.exit("Long exit", from_entry = "Long", stop = longStop, limit = vwapValue)
if strategy.position_size < 0
strategy.exit("Short exit", from_entry = "Short", stop = shortStop, limit = vwapValue)
// No overnight risk: flatten before the bell
if inFlat and strategy.position_size != 0
strategy.close_all(comment = "EOD flat")
// ---------------- Visuals ----------------
plot(vwapValue, "VWAP", color = color.new(color.orange, 0), linewidth = 2)
pu = plot(upperBand, "Upper band", color = color.new(color.red, 30))
pl = plot(lowerBand, "Lower band", color = color.new(color.teal, 30))
fill(pu, pl, color = color.new(color.gray, 94), title = "VWAP envelope")
bgcolor(tradeable ? na : color.new(color.gray, 90), title = "No-trade period")
A few things worth understanding rather than just running:
process_orders_on_close = truefills the entry at the close of the signal bar instead of the next bar's open. It's the honest choice here, because the signal is a close beyond the band, and it keeps the ATR stop referenced to a price you actually saw.strategy.position_size == 0enforces one position at a time. Without it, a long grind under the lower band stacks entry after entry into the same losing move.bgcolorshades every bar where the filters block trading. Turn it on for a day and you'll see instantly whether your regime filter is too strict.strategy.close_allin the flat window is what makes this genuinely intraday. VWAP resets tomorrow, so holding a VWAP-target position overnight means holding a target that ceases to exist.
Tuning the parameters
Five inputs actually change the character of the strategy. The rest are housekeeping.
| Input | Default | What it controls | When to change it |
|---|---|---|---|
| Entry band (SD) | 2.0 | How far price must stretch before you fade it | Lower to 1.5 for more signals on quiet index products; raise to 2.5 on high-beta names where 2.0 fires constantly |
| Warm-up bars | 6 | How long after the open before entries are allowed | Raise to 12 on 1-minute charts; drop to 2 or 3 on 15-minute |
| Max ADX | 25 | The trend cutoff that blocks fading | Tighten to 20 if trend-day losses dominate; loosen to 30 if you're getting almost no fills |
| Stop (x ATR) | 1.5 | Distance from fill to stop | Widen to 2.0 if you're being stopped and then seeing the reversion; never tighten below 1.0, band touches are noisy by definition |
| Entry window | 0930-1545 | When entries are permitted | Cut to 1000-1500 to skip both the open and the last-hour imbalance auction |
Change one at a time and record the result. Sweeping three inputs at once teaches you nothing about which one mattered, and it's the fastest route to a curve fit that dies on live data.
Reading the results in TradingView's Strategy Tester
Once the script is on the chart, the Strategy Tester tab at the bottom prices every rule you wrote. TradingView's broker emulator runs the fills, and its strategy documentation explains exactly how intrabar order execution is modeled, which matters when your stop and target sit inside the same bar.
Four numbers tell you whether the template has anything on your instrument:
- Total trades. Under about 100 in the backtest window, stop reading the rest. The sample is too small to distinguish skill from a good month.
- Profit factor. Gross profit over gross loss, and the cleanest single read on whether an edge survives costs. A number near 3.0 on an unoptimized template is a reason to check the test for look-ahead bias, not a reason to celebrate.
- Max drawdown. The number that decides your size. This is where trend days show up.
- Average trade. Compare it against your real commission and spread. A strategy whose average trade is smaller than its round-trip cost is a losing strategy that looks profitable in a test with optimistic assumptions.
The backtesting metrics breakdown goes deeper on what each of these hides. The short version: net profit is the least useful number on the screen, and it's the one everybody looks at first.
Test across at least one trending regime and one chopping regime. A VWAP fade tested only on a range-bound quarter will look extraordinary and then hand it all back the first time the market picks a direction.
Where VWAP mean reversion breaks
❌ Using VWAP across multiple days. Dragging a VWAP-based strategy onto a daily chart produces a line that has been accumulating since the beginning of the data. It's not fair value, it's an artifact.
✅ Anchor with timeframe.change("D") and trade intraday bars only. For multi-day fades, use a different anchor entirely, or a reversion model built for holds measured in days rather than hours.
❌ Fading every band touch. Price touching 2 SD on a strong trend day isn't an extension, it's the trend. Unfiltered band fading is the single largest source of losses in this style. ✅ Gate entries on ADX(14) below 25 and require a reversal close. Both filters are in the code above, and both cost you signals you're better off not taking.
❌ Setting the target once and forgetting it. VWAP at 10:00 and VWAP at 11:30 are different prices. A static limit order aims at a stale one.
✅ Re-issue strategy.exit with the same ID every bar so the order gets modified rather than duplicated.
❌ Trading it on spot forex. No centralized volume means the weighting is built on your broker's tick count, and two brokers will give you two different VWAPs on the same pair. ✅ Stick to futures, equities, and major crypto pairs where reported volume reflects actual size traded.
❌ Assuming it repaints. People see the VWAP line adjust during a forming bar and assume the whole thing is untrustworthy.
✅ Understand the actual behavior. VWAP updates as the current bar builds, like any indicator using close. Once the bar closes, its value is fixed and the historical line never changes. Entries evaluated on bar close are stable, and TradingView's repainting reference covers the cases that genuinely do move.
Pro tips
- Shade the no-trade periods before you optimize anything. The
bgcolorline costs nothing and will show you within one session that your filters are rejecting 80% of the day, if they are. - Watch which band the day opens against. Sessions that gap and immediately trade away from VWAP without returning in the first hour are trend days. Some traders disable the strategy entirely on any day where price hasn't crossed VWAP by 11:00.
- Volume confirms the fade. An extension on falling volume is far more likely to revert than one on expanding volume. Add
volume < ta.sma(volume, 20)totradeableand compare the two versions in the tester. - Log every trade's distance from VWAP at entry. If your winners cluster around 2.2 SD and your losers around 1.6, your entry band is set too low, and no amount of stop tuning will fix that.
- Set alerts instead of watching.
alertcondition(longSignal, "VWAP fade long")on the indicator version gives you the setup without sitting on the chart all session.
Generating this without writing the code yourself
Everything above is a specification, and a specification is exactly what HorizonAI turns into code. Describing the strategy in chat gives you working, compile-checked Pine Script v6 you can keep editing in the same conversation.
The prompt for the full build:
Build a Pine Script v6 intraday strategy that plots session-anchored VWAP with 2 standard deviation bands using ta.vwap. Go long when a bar closes below the lower band and closes above its open, short on the mirror condition. Target VWAP as a limit that updates every bar, stop at 1.5x ATR(14) fixed from entry, entries only between 09:30 and 15:45 New York time, and close all positions in the 15:50-16:00 window.
Then one follow-up to add the regime filter:
Add an ADX(14) filter so entries are only taken when ADX is below 25, expose the threshold as an input, and shade the chart background on bars where the filters block trading.
What comes back is a complete script, validated against a real Pine compiler rather than guessed at, so syntax errors get caught and explained before you ever open the Pine Editor. From there you edit by asking, swapping the ATR stop for a fixed percentage or changing the anchor to weekly, and you copy the finished code into TradingView yourself to run it. If you'd rather run this style of system in MetaTrader, the same description generates MQL5, and MQL5 strategies can be backtested inside HorizonAI directly.
FAQs
What standard deviation setting works best for VWAP bands?
Start at 2.0 and adjust to the instrument's behavior, not to backtest results. If 2.0 fires more than a handful of times per session, the band is too tight for that symbol and 2.5 will filter the noise. Liquid index products often work better nearer 1.5.
Does VWAP repaint in Pine Script?
No, not once the bar closes. The value updates while the current bar is forming, the same as any calculation referencing close, but historical values are fixed and never redrawn. Evaluating signals on closed bars removes the ambiguity entirely.
Can I use this strategy on crypto that trades 24/7?
Yes, though the session anchor becomes a choice rather than a given. Most crypto traders anchor VWAP to 00:00 UTC, which timeframe.change("D") already does. Drop the entry-window and force-flat blocks, and replace them with a maximum hold time so positions don't run indefinitely.
Why is my strategy taking no trades?
Work down the filter stack in order: check that your chart timeframe is intraday, that the session string matches the exchange's actual hours, that the warm-up bar count isn't consuming most of a short session, and that ADX isn't sitting above your cutoff for the whole test window. The bgcolor shading identifies the culprit faster than reading the code.
Should the target be VWAP or something closer?
VWAP is the full reversion, and full reversion doesn't always arrive. Some traders take partial profit at the 1 SD band and let the rest run to VWAP, which you'd implement with two strategy.exit calls using qty_percent. Test both, since the answer depends on how far your instrument typically retraces.
Final thoughts
The strategy above isn't a finished system, and it shouldn't be treated as one. It's a correct, complete template with every rule made explicit, which means every rule is now something you can test, measure, and change on purpose. That's the difference between a strategy and a chart with lines on it.
One last thing worth doing before you tune anything: run the template unchanged for two weeks on a single instrument and keep a note of every trade the ADX filter blocked. If most of those blocked trades would have lost, the filter is earning its place. If most would have won, you've found your first real edit, and you found it from evidence instead of from a parameter sweep.
Related articles
- Mean Reversion Trading Strategies That Actually Work — the wider family of reversion setups, from RSI bounces to band fades
- Swing Trading Mean Reversion — the multi-day version, for when VWAP's session reset gets in the way
- Bollinger Bands Strategy for Day Trading — the other standard-deviation band model, and how it differs from VWAP
- 7 Best Technical Indicators for Day Trading — what pairs well with a VWAP fade as a confirmation layer
- Pine Script Tutorial for Beginners — the syntax foundation this build assumes
- 7 Best Pine Script Strategies for TradingView — more complete strategy scripts with code
- How to Backtest a Trading Strategy — designing a test that won't lie to you
- Understanding Backtesting Metrics — what profit factor and drawdown actually tell you
- ATR Indicator Explained — the volatility measure behind the stop distance
- Risk Management in Trading — position sizing that survives a run of trend days
Questions about VWAP mean reversion? Join our Discord to discuss with other traders!
