New playbooks in your inbox
Hands-on tutorials for people who want to build with AI.

Why Your Pine Backtest Lies: the request.security Fix

Pine script request.security repaint and lookahead bias inflate a 47% strategy to 61%. The lookahead_on plus [1] offset pattern that returns the honest number.

From the youcanbuildthings catalog ▸ Build-tested 8 min read

Summary:

  1. Teaches the one request.security pattern that kills both realtime repaint and the silent look-ahead that inflates a backtest.
  2. Proven with the same strategy run twice: one code character is the only difference.
  3. The inflated run reads 61% win rate; the honest run reads 47%. The 14 points are the lie.
  4. Deliverable: the copy-paste lookahead_on + [1] pattern and the two free repaint tests.

The pine script request.security repaint problem is the reason your backtest reads 61% and your live account reads 47%. Same strategy. Same chart. The only difference is one character of code: whether the expression carries a [1] offset. This is the most-misunderstood pattern in the entire Pine ecosystem, and most Reddit threads give advice that is exactly backwards.

Here is the proof. Take a multi-timeframe RSI strategy, run a 12-month SPY backtest twice, change one thing.

Two stacked 12-month SPY equity curves, January to December. Top, without the [1] offset, lookahead bias on: Win Rate 61%, Profit Factor 2.1, Max Drawdown 8%, Total Return +124.6%, smooth curve, labeled inflated. Bottom, with the [1] offset, lookahead bias off, the truth: Win Rate 47%, Profit Factor 1.3, Max Drawdown 12%, Total Return +58.3%, choppy curve. A callout marks the 14-point win-rate lie.

What is request.security repaint?

Repaint is when an indicator draws one thing in real time and a different thing after the bar closes. With request.security, it happens because the default lookahead = barmerge.lookahead_off returns the developing value of the higher-timeframe bar on realtime bars. As the 4-hour candle moves, your pulled level moves. Every tick. Scroll back later and the lines look stable, because all those bars are now closed. The live trader watched them dance.

Most threads tell you to use lookahead_off “to avoid repaint.” That is wrong. lookahead_off is the default and is the source of the realtime repaint. TradingView’s own repainting docs are blunt about the related trap on the other side:

“When request.security() is used with lookahead = barmerge.lookahead_on to fetch prices without offsetting the series by [1], it will return data from the future on historical bars, which is dangerously misleading.”

(TradingView Pine Script docs, Repainting.)

So both naive options are traps. lookahead_off repaints in real time. lookahead_on alone leaks the future on history. Here is the thing: the advice you have read a hundred times picks one trap to avoid the other. The fix is the combination.

The one pattern that is honest

The non-repainting, non-leaking pattern is lookahead_on plus a [1] history offset on the expression:

htfClose = request.security(syminfo.tickerid, "240", close[1],
                            lookahead = barmerge.lookahead_on)

Three things make it work. The timeframe argument is "240", a string (Pine v6 accepts a series string here; v5 required simple). The expression is close[1], not close. The [1] is the offset that prevents the leak. And lookahead = barmerge.lookahead_on is named explicitly, never omitted. TradingView documents exactly this:

“One can ensure higher-timeframe data requests only return confirmed values on all bars, regardless of bar state, by offsetting the expression argument by at least one bar with the history-referencing operator and using barmerge.lookahead_on for the lookahead argument.”

The [1] rolls the request back to the previous, fully-confirmed higher-timeframe bar. Even with lookahead_on, the value returned is the close of the last closed HTF bar. No leak. No repaint. Same value on historical and realtime bars.

What broke: the 14-point lie

Here is the same strategy, run twice, with the only code change being the offset. The image is the receipt.

Without the offset, lookahead bias on:

rsi = ta.rsi(close, 14)        // [0] no offset
  • Win Rate: 61%
  • Profit Factor: 2.1
  • Max Drawdown: 8%
  • Total Return: +124.6%

With the offset, lookahead bias off, the truth:

rsi = ta.rsi(close, 14)[1]     // [1] THE OFFSET
  • Win Rate: 47%
  • Profit Factor: 1.3
  • Max Drawdown: 12%
  • Total Return: +58.3%

That is a 14-point win-rate gap. Profit factor collapses 2.1 to 1.3. Max drawdown was understated at 8% when the honest number is 12%. Same code, same chart, one parameter different. Live, the market delivers the 47% / 1.3 / 12% version every time, because the market does not trade on tomorrow’s close. The +124.6% return was fiction the moment the algorithm got to peek.

This is not a rare bug. A meaningful fraction of “amazing strategy” scripts on TradingView’s public library have exactly this. If you paste community Pine and the backtest looks too good, the first thing to check is whether the HTF references carry [1] offsets.

How to catch repaint in five minutes (no paid plan)

Bar Replay is the clean test, but it is a paid feature. Two free workarounds catch the same bug.

  1. Compare-snapshot. Load the indicator. Screenshot a historical region. Wait 60 minutes. Screenshot the same range again. If the levels sit at different prices in the same historical region, it repainted: the realtime bar moved, the script regenerated its history off the new data. The lookahead_on + [1] pattern produces identical screenshots an hour apart.
  2. Two-tabs. Open the same chart in two browser tabs, indicator on both. In tab one, switch the timeframe up and back. Leave tab two alone. Compare the historical level positions. If they differ, the script regenerated off different bar boundaries, another flavor of repaint. The honest pattern produces identical positions because close[1] with lookahead_on does not depend on the realtime bar at all.

Run one of these on every higher-timeframe indicator you build, not just the suspicious ones.

How to fix it in your own script

  1. Find every request.security call. Grep the file for request.security(.
  2. Check the third argument. If the expression has no [1] (it reads close, ta.ema(close, 50), ta.pivotlow(...)), it is a candidate.
  3. Add the [1] offset to the expression and name lookahead = barmerge.lookahead_on explicitly. ta.ema(close, 50) becomes ta.ema(close, 50)[1].
  4. Re-run the backtest. The win rate and profit factor should drop. That drop is fake performance leaving. The lower number is the real one.
  5. Confirm no repaint with one of the two free tests above.

If you drive Claude Code, put the rule in your CLAUDE.md so it never generates the broken pattern again:

- All request.security HTF requests: use lookahead = barmerge.lookahead_on
  with a [1] offset on the expression. Never lookahead_off to "avoid repaint".

What should you actually do?

  • If your backtest looks too smooth or the equity curve has no 10%-plus drawdown → check every request.security for the [1] offset first. Smooth curves are usually look-ahead, not edge.
  • If a level drifts during the live bar but looks fine in history → that is lookahead_off realtime repaint. Switch to lookahead_on + [1], do not “fix” it with more lookahead_off.
  • If you pasted a public-library script that backtests at 90%+ → assume look-ahead until the snapshot test says otherwise.
  • If you let Claude write HTF logic → add the rule to CLAUDE.md so the broken pattern stops being generated.

bottom_line

  • Default to [1]. A higher-timeframe request without a history offset is fiction until proven otherwise.
  • The honest backtest is the lower number. A correction that drops your win rate 14 points did not break your strategy; it told you the truth about it.
  • TradingView’s own House Rules forbid publishing scripts that use lookahead to produce misleading results. The pattern that gets you published is the same one that keeps your live account intact.
Why trust this? Every youcanbuildthings guide is pulled from a build-tested book — code that ran in production before it was written down.

Frequently Asked Questions

Does lookahead_off stop Pine Script from repainting?+

No. lookahead_off is the default and on realtime bars it returns the developing higher-timeframe value, which is exactly what repaints. The non-repainting pattern is lookahead_on plus a [1] offset on the expression.

Why does my Pine backtest look amazing but lose money live?+

Almost always silent look-ahead. request.security with lookahead_on and no [1] offset lets historical bars read a higher-timeframe bar's final value before it would have been known. The backtest sees the future; the live market does not.

What is the one request.security pattern I should always use?+

request.security(syminfo.tickerid, "240", close[1], lookahead = barmerge.lookahead_on). The [1] rolls the expression back to the last confirmed higher-timeframe bar, so the value is honest on both historical and realtime bars.