Mean Reversion Strategy in Pine Script: High Win Rate, Low Edge
By HorizonAI Team
Mean reversion strategies do win often. A 70% to 85% win rate is normal for them, and by itself it proves nothing. Win rate is set by geometry: a close target and a wide stop win most of the time and lose big when they don't. Fade a 2-sigma stretch with a 2.5 ATR stop and you can post 78% winners while your account slowly bleeds.
The strategy below is working Pine Script v6 you can paste straight into TradingView, and it prints its own expectancy on the chart so you can see which side of breakeven you are actually on.
The 30-second version:
- Breakeven win rate = 1 ÷ (1 + payoff ratio), where payoff ratio is your average win divided by your average loss. At a 0.3:1 payoff you need 76.9% just to stand still.
- In a driftless market, the odds of touching a target
apoints away before a stopbpoints away areb ÷ (a + b). With a 0.3R target and a 1R stop that's 76.9%, the same number. The structural win rate and the breakeven win rate are identical, which is why a high win rate on its own is not evidence of anything. - Your edge is the gap between your realized win rate and that breakeven figure, minus commission, slippage, and the tail losses that gap through your stop.
- A 78% win rate at 0.3:1 leaves roughly a one-point margin. Round-trip costs of 0.04% against a 0.35% average win take a double-digit bite out of every gross win, and they are charged on the losers too.
- Build for expectancy per trade, not win rate. The win rate is a description of your exit geometry, not a score.
What a high win rate in mean reversion actually tells you
Mean reversion assumes price oscillates around a fair value and that stretched moves snap back. The mean can be a 20-period SMA, VWAP, the Bollinger middle band, or a range midline. Whichever you pick, the trade structure is the same: you buy a dip below it and you sell the return to it.
That structure produces a high win rate mechanically. Your target sits maybe one ATR away. Your stop has to sit well beyond the stretch that got you interested, or normal noise takes you out before the reversion has a chance. Small target, big stop, lots of winners, occasional brutal loser. Nothing about that sequence requires you to be right about anything.
Here is the uncomfortable part. Take a coin-flip entry, set the target at 0.3R and the stop at 1R, and a random walk will hand you a 76.9% win rate. Any strategy with that exit geometry starts at 77% before its signal contributes a single basis point. So when a mean reversion system shows 78%, the question is not "is that good" but "how much of that 78% is the geometry and how much is the entry?"
Key takeaway: a high win rate is the cost of mean reversion's payoff profile, not a benefit of it. You are paying for those frequent small wins with rare large losses.
The number that decides it: expectancy
Expectancy is the average dollars (or R) a trade returns over a large sample:
Expectancy = (Win% × Average win) − (Loss% × Average loss)
Two versions of the same system make the point. Both run 100 trades on the same symbol.
System A, mean target, 2.5 ATR stop:
- Win rate: 78% (78 wins, 22 losses)
- Average win: $60
- Average loss: $220
- Expectancy: (0.78 × 60) − (0.22 × 220) = 46.80 − 48.40 = −$1.60 per trade
That system loses $160 over 100 trades before costs. Add $2 of round-trip commission and slippage per trade and the loss more than doubles, to −$360.
System B, same entries, target extended to 1R and the stop tightened:
- Win rate: 61% (61 wins, 39 losses)
- Average win: $110
- Average loss: $150
- Expectancy: (0.61 × 110) − (0.39 × 150) = 67.10 − 58.50 = +$8.60 per trade
The system with the worse win rate makes $860 over the same 100 trades. Seventeen percentage points of win rate went away and the account went up. That is the entire argument, and it is why backtesting metrics like profit factor and expectancy exist as separate numbers from "percent profitable".
The breakeven win rate table
Pin your payoff ratio, and arithmetic tells you the win rate you need before you have made a single dollar:
| Payoff ratio (avg win : avg loss) | Win rate needed to break even |
|---|---|
| 0.25 : 1 | 80.0% |
| 0.33 : 1 | 75.0% |
| 0.50 : 1 | 66.7% |
| 0.75 : 1 | 57.1% |
| 1.00 : 1 | 50.0% |
| 1.50 : 1 | 40.0% |
| 2.00 : 1 | 33.3% |
Mean reversion systems live in the top three rows. A trader posting "78% win rate" who does not know their payoff ratio has no idea whether they are above or below their own breakeven line, and the two numbers are usually within a couple of points of each other.
Costs push the requirement up further. Fees and slippage subtract from every win and add to every loss, so the true breakeven sits above the table value, and it sits further above it the smaller your average win is. Mean reversion has the smallest average win of any common strategy family, which makes it the most cost-sensitive.
Then there are the tails. Your stop is a request, not a guarantee. Gaps, halts, and thin sessions produce losses larger than the modeled stop, and a single 3R loss erases the profit from roughly ten winners at a 0.3:1 payoff. Measure your worst five losses separately from the average, because the average hides exactly the number that hurts you.
The strategy: fading a 2-sigma stretch, with the trend
Rules first, code second. Every parameter here is a starting point you should test, not a setting anyone claims is optimal.
Market and timeframe
- Liquid, genuinely rotational instruments: index ETFs and futures, large-cap equities, major FX pairs
- 1H or daily bars for the swing version, 5m for the intraday version
- Not thin small caps, not a token mid-parabola, not anything with an earnings release inside the holding window
Entry (long only)
- The mean: 20-period SMA of close
- The stretch: z-score = (close − SMA20) ÷ stdev(close, 20), and the z-score must be at or below −2.0
- The regime filter: close must be above the 200 EMA. Buying dips in a downtrend is where mean reversion goes to die
- Entry on the close of the confirmed bar, one position at a time
Exit
- Stop: 2.5 × ATR(14) below the fill, fixed at entry and never widened
- Target: the 20 SMA itself, re-issued every bar so the target follows the mean rather than sitting where the mean used to be
- Time stop: 10 bars. If the snapback has not started, the premise was wrong and the trade is now a directional bet you never intended to take
Size
- Risk 1% of equity per trade
- Quantity = (equity × 1%) ÷ stop distance, so ATR sizes the position for you and a volatility spike does not silently multiply your risk
Long only is deliberate. Equity indices have upward drift, which quietly subsidizes long mean reversion and quietly taxes the short side. Run the mirrored short logic as a separate test before assuming symmetry.
The full Pine Script v6 strategy
//@version=6
strategy("Mean Reversion Z-Score (Long Only)",
overlay = true,
initial_capital = 10000,
currency = currency.USD,
commission_type = strategy.commission.percent,
commission_value = 0.02,
slippage = 2,
calc_on_every_tick = false,
process_orders_on_close = true)
// ---------- Inputs ----------
meanLen = input.int(20, "Mean length (bars)", minval = 5, group = "Signal")
zEntry = input.float(2.0, "Entry stretch (z-score)", minval = 0.5, step = 0.1, group = "Signal")
trendLen = input.int(200, "Trend filter EMA", minval = 20, group = "Signal")
atrLen = input.int(14, "ATR length", minval = 2, group = "Risk")
stopMult = input.float(2.5, "Stop distance (x ATR)", minval = 0.5, step = 0.1, group = "Risk")
riskPct = input.float(1.0, "Risk per trade (% equity)", minval = 0.1, step = 0.1, group = "Risk")
maxBars = input.int(10, "Time stop (bars in trade)", minval = 1, group = "Risk")
// ---------- The mean, and the stretch away from it ----------
basis = ta.sma(close, meanLen) // the fair value we fade back to
sd = ta.stdev(close, meanLen) // dispersion around it
zScore = sd > 0 ? (close - basis) / sd : 0.0 // how many standard deviations we are stretched
trendMa = ta.ema(close, trendLen) // regime filter
atrValue = ta.atr(atrLen) // volatility unit for the stop
// ---------- Entry ----------
uptrend = close > trendMa
stretched = zScore <= -zEntry
flat = strategy.position_size == 0
// barstate.isconfirmed stops the signal flickering on the live bar
longSignal = stretched and uptrend and flat and barstate.isconfirmed
stopDistance = stopMult * atrValue
riskCash = strategy.equity * riskPct / 100.0
qty = stopDistance > 0 ? riskCash / stopDistance : 0.0
var float entryStop = na
if longSignal and qty > 0
strategy.entry("MR Long", strategy.long, qty = qty)
entryStop := close - stopDistance // frozen at entry, never widened
// ---------- Exits ----------
// The stop is fixed. The target is the mean itself, re-issued each bar so it
// tracks the SMA instead of sitting where the mean was when we entered.
if strategy.position_size > 0 and not na(entryStop)
strategy.exit("MR Exit", from_entry = "MR Long", stop = entryStop, limit = basis)
// Time stop: no snapback within maxBars bars means the premise is stale.
if strategy.opentrades > 0
barsHeld = bar_index - strategy.opentrades.entry_bar_index(strategy.opentrades - 1)
if barsHeld >= maxBars
strategy.close("MR Long", comment = "Time stop")
// ---------- Visuals ----------
plot(basis, "Mean (SMA)", color = color.new(color.blue, 0), linewidth = 2)
plot(trendMa, "Trend EMA", color = color.new(color.orange, 0), linewidth = 2)
plot(basis - zEntry * sd, "Entry band", color = color.new(color.teal, 40))
bgcolor(uptrend ? color.new(color.red, 100) : color.new(color.red, 92), title = "No-trade regime")
How the code works
The z-score is the whole signal. Bollinger Bands are the same calculation wearing a costume: the lower band at 2 standard deviations is a z-score of −2. Working with the raw z-score means the entry threshold is a single number you can test across a range instead of two coupled settings. The sd > 0 guard matters on illiquid symbols where a flat 20-bar window makes the denominator zero.
process_orders_on_close = true and barstate.isconfirmed do the anti-repaint work. Orders fill at the close of the bar that generated the signal, which is what you could actually have done in real time. TradingView's own repainting documentation calls out barstate.isconfirmed as the reliable way to hold a signal until the bar is finished, and calc_on_every_tick = false keeps historical and realtime behavior aligned.
entryStop is frozen with := at entry. ATR moves every bar, so a stop recomputed from the live ATR would drift, usually wider, exactly when volatility spikes and you least want it to. Capturing the value once is the difference between a fixed risk and a moving one.
strategy.exit is called on every bar the position is open. That is intentional and not a duplicate order. Each call updates the existing exit orders, which lets the limit price follow basis as the mean falls toward price. The stop argument passes the frozen entryStop, so the target moves and the risk does not.
The time stop uses strategy.opentrades.entry_bar_index(). Passing strategy.opentrades - 1 addresses the most recent open trade, so the logic keeps working if you later allow pyramiding. Full argument lists for these are in the Pine Script v6 reference, and the broker emulator's fill rules are worth reading in the strategies documentation before you trust any result.
If any of that syntax is unfamiliar, the Pine Script tutorial for beginners covers the language basics this script assumes.
Put your expectancy on the chart
The Strategy Tester reports "Percent Profitable" in large friendly type and buries the numbers that decide profitability. This panel appends to the script above and puts the numbers that actually matter on the chart, including the breakeven win rate your current payoff ratio demands. Argument order for the table calls follows TradingView's tables documentation.
// ---------- Expectancy panel (append to the strategy above) ----------
var table stats = table.new(position.top_right, 2, 6,
bgcolor = color.new(color.black, 20), border_width = 1)
// Note the parameter is named `caption`, not `label`: `label` is a built-in
// Pine type, and shadowing it here makes table.cell reject the argument.
statRow(int r, string caption, string value) =>
table.cell(stats, 0, r, caption, text_color = color.gray, text_size = size.small, text_halign = text.align_left)
table.cell(stats, 1, r, value, text_color = color.white, text_size = size.small, text_halign = text.align_right)
if barstate.islast and strategy.closedtrades > 0
// 100.0 forces float maths: int / int would truncate the win rate to a whole number
winRate = 100.0 * strategy.wintrades / strategy.closedtrades
avgWin = strategy.wintrades > 0 ? strategy.grossprofit / strategy.wintrades : 0.0
avgLoss = strategy.losstrades > 0 ? strategy.grossloss / strategy.losstrades : 0.0
payoff = avgLoss > 0 ? avgWin / avgLoss : 0.0
expectancy = (winRate / 100.0) * avgWin - (1.0 - winRate / 100.0) * avgLoss
breakeven = payoff > 0 ? 100.0 / (1.0 + payoff) : 0.0
statRow(0, "Closed trades", str.tostring(strategy.closedtrades))
statRow(1, "Win rate", str.tostring(winRate, "#.0") + "%")
statRow(2, "Avg win", str.tostring(avgWin, "#.00"))
statRow(3, "Avg loss", str.tostring(avgLoss, "#.00"))
statRow(4, "Payoff (win/loss)", str.tostring(payoff, "#.00"))
statRow(5, "Breakeven win rate", str.tostring(breakeven, "#.0") + "%")
Read the win rate line against the breakeven line. If your win rate is not clearly above the breakeven figure across at least 100 closed trades, the system has no edge, however good the first number looks in isolation. strategy.grossloss is reported as a positive figure by TradingView, which is why the expectancy line subtracts it rather than adding it.
Turning the win rate dial, and what it costs you
Win rate is a setting, not a discovery. Change the exit and you can put it almost anywhere you like, and the breakeven bar moves with it in lockstep. Stop fixed at 2.5 ATR, target varied:
| Exit rule | Payoff (target ÷ stop) | Breakeven win rate |
|---|---|---|
| Target at the mean, roughly 0.9 ATR away | 0.36 : 1 | 73.5% |
| Fixed 0.5R target | 0.50 : 1 | 66.7% |
| Fixed 1R target | 1.00 : 1 | 50.0% |
| Fixed 2R target | 2.00 : 1 | 33.3% |
| Mean target, stop widened to 4 ATR | 0.23 : 1 | 81.3% |
Look at the last row. Widening the stop from 2.5 to 4 ATR raises the observed win rate, which feels like progress, and raises the required win rate by about the same amount, which is not progress at all. Before costs it is a wash. After costs it is worse, because the bigger loss on the trades that do fail is now taking a larger bite.
Swapping the mean target for a fixed R multiple means replacing the exit block:
// Drop-in replacement for the exit block: fixed R target instead of the mean
rMultiple = input.float(1.0, "Target (x risk)", minval = 0.25, step = 0.25, group = "Risk")
if strategy.position_size > 0 and not na(entryStop)
riskPerUnit = strategy.position_avg_price - entryStop
target = strategy.position_avg_price + rMultiple * riskPerUnit
strategy.exit("MR Exit", from_entry = "MR Long", stop = entryStop, limit = target)
Run both versions on the same symbol and date range and compare expectancy per trade, never win rate against win rate. One of them will look worse on the metric traders quote and better on the metric that pays.
Filters that raise expectancy instead of win rate
A filter is only worth its complexity if it moves expectancy. These four do, because each one removes trades from the part of the distribution where the tail losses live:
- Trade the range, skip the trend. ADX(14) below 20, or a 200 EMA whose slope is near flat over the last 20 bars. The catastrophic mean reversion losses are almost all trades taken while a trend was accelerating. When conditions flip, a trend following approach is the appropriate tool rather than a wider stop.
- Skip scheduled events. Earnings, central bank decisions, and index rebalances generate the gaps that overshoot stops. Giving up a handful of ordinary trades to avoid the losses that blow past your stop is a good trade at these payoff ratios, since it takes about ten winners to repay one of them.
- Cap correlated exposure. Five mean reversion longs across five index constituents is one trade at 5x size, and it will feel like it on the day the index sells off. One open position per correlated cluster.
- Check the cost ratio before you trade the timeframe. Divide round-trip cost by expected average win. Above roughly 10%, drop to a slower timeframe where the target is bigger rather than tuning entries harder.
Common mistakes that kill mean reversion systems
❌ Judging the system by win rate. ✅ Judge it by expectancy per trade in R, over 100+ closed trades, with the worst five losses inspected individually.
❌ Widening the stop after a loss because "it always comes back". ✅ Freeze the stop at entry, as entryStop does above. Widening converts a defined loss into an undefined one.
❌ Averaging down into the stretch. ✅ One entry per signal. Adding to a loser in a mean reversion system is a martingale with a friendly name, and it turns the rare large loss into an account-ending one.
❌ Optimizing the z-score threshold across 40 trades on one symbol. ✅ Test across several symbols and both halves of your data. A threshold that only works at 2.1 and not at 1.9 or 2.3 is curve fit, and the backtesting mistakes guide covers how to structure the test properly.
❌ Backtesting with zero commission and zero slippage. ✅ Set them before the first run, not after. Mean reversion's small average win makes it the strategy family most likely to flip from profitable to unprofitable when realistic costs go in.
Generating this strategy without writing the Pine yourself
Every variation above is a change of a few lines, and testing variations is most of the work in strategy development. HorizonAI generates Pine Script v6 from a plain-English description, checks it against a real compiler, and lets you edit it in chat, so a variant takes a sentence rather than a debugging session.
"Write a Pine Script v6 long-only mean reversion strategy that enters when the 20-bar z-score of close drops to −2 and price is above the 200 EMA, stops out at 2.5 x ATR(14) fixed at entry, targets the 20 SMA, closes after 10 bars, and sizes each trade at 1% of equity."
"Add an on-chart table to my strategy showing closed trades, win rate, average win, average loss, payoff ratio, and the breakeven win rate implied by that payoff."
"Take the same strategy and replace the mean target with a fixed 1R target, then tell me which lines changed and why."
If you are on Pro or Elite, the HorizonAI Chrome extension runs the generated strategy through TradingView's Strategy Tester in your own browser and returns the results to the chat, so the expectancy comparison happens without leaving the conversation. New accounts get a one-time 7-day trial with no card required.
Try it free →FAQs
Do mean reversion strategies really have a high win rate?
Yes, and it is structural rather than predictive. Mean reversion exits take a small target while the stop sits far enough away to survive normal noise, and that geometry alone produces a high proportion of winners. A random entry with a 0.3R target and a 1R stop wins roughly 77% of the time in a driftless market, so a mean reversion system showing 78% has barely improved on a coin flip with the same exits.
What win rate does a mean reversion strategy need to be profitable?
Divide 1 by (1 + your payoff ratio), where the payoff ratio is your average win divided by your average loss. At 0.33:1 you need 75%, at 0.5:1 you need 66.7%, and at 1:1 you need 50%. Then add a margin for commission, slippage, and losses that gap through your stop. Any win rate above that adjusted number is your actual edge, and it is usually a lot smaller than the headline figure suggests.
Why does my high win rate strategy still lose money?
Almost always because the average loss is several times the average win and the win rate sits just under the breakeven line. Seventy-eight wins at $60 do not cover twenty-two losses at $220, and that is a 78% win rate. Calculate expectancy as (win rate × average win) minus (loss rate × average loss); if it comes out near zero or negative, no amount of extra winners will fix it without changing the payoff ratio or the costs.
Does this Pine Script repaint?
No. The strategy declaration sets calc_on_every_tick = false and process_orders_on_close = true, and the entry condition includes barstate.isconfirmed, so signals are only evaluated on closed bars and orders fill at that bar's close. The exit orders are re-issued each bar, which changes the resting target price as the mean moves but never rewrites a fill that already happened.
Which timeframe works best for mean reversion?
Pick the slowest timeframe where the setup still appears often enough to give you a sample. Round-trip costs are fixed per trade while the average win scales with the timeframe, so a 1-minute mean reversion system hands most of its gross profit to fees while the same rules on 1-hour or daily bars keep it. Start on 1H, confirm the cost ratio sits under 10% of the average win, and only go faster if it does.
Final Thoughts
Mean reversion is a legitimate strategy family with an unusually misleading headline metric. The win rate is high because the exits are built that way, and the same geometry that produces it also sets the bar you have to clear, so the two numbers cancel out and leave you with expectancy as the only honest scorekeeper.
One concrete habit to leave with: before you accept any mean reversion backtest, look up its average win and average loss, compute 1 ÷ (1 + avgWin/avgLoss), and write that percentage next to the win rate. If the gap is under three points, you are looking at exit geometry, not an edge, and the costs will take the rest.
Related Articles
Take mean reversion further with these guides:
- Understanding Backtesting Metrics — Win rate, profit factor, and expectancy explained properly
- How to Backtest a Trading Strategy — Build a test your results survive
- Trading Risk Management Guide — Position sizing that matches your stop
- ATR Indicator Explained — The volatility unit behind the stop and the sizing
- Bollinger Bands Trading Strategy — The same z-score in indicator form
- Swing Trading Mean Reversion — The multi-day version of these rules
- Trend Following Strategy Guide — What to trade when the mean stops holding
- RSI vs MACD Comparison — Choosing an oscillator for the entry filter
- Best Indicators for Day Trading — Intraday tools ranked
- Pine Script Tutorial for Beginners — The language this strategy is written in
Questions about win rate, expectancy, or the Pine above? Join our Discord to discuss with other traders!
