You tuned a moving average crossover until the Strategy Tester curve looked clean, then you left it running on a fresh month of bars and watched it fall apart. Nothing in the code changed. What changed is that the settings had only ever been judged on data they were fitted to. Walk-forward testing is the habit that exposes this before the fresh month does, and you can build most of it in plain Pine Script with a handful of inputs.
This guide shows how to split a strategy's history into a fitting window and a held-back window, how to roll that split forward in folds, and how to read the numbers without fooling yourself. The code runs in TradingView's Strategy Tester, and everything here is about measuring a script's behavior on past data, never about predicting what comes next.
Why one backtest tells you so little
A single backtest over the whole chart answers one question: how would these exact settings have behaved on this exact stretch of history? When you also chose the settings by looking at that same stretch, the answer is flattering by construction. You tried a fast length of 8, then 10, then 12, and kept the one that looked best. The winning number is partly a real property of the idea and partly a fit to noise that will not repeat.
The more settings you can change, the more room there is to fit noise. A strategy with two inputs and ten values each has a hundred combinations. Keep the best of a hundred and you are almost guaranteed a good-looking run, even if the idea has nothing to it. This is usually called overfitting or curve fitting, and it is the main reason a backtest and live behavior disagree. Our post on why backtest results differ from live trading covers the other causes, such as fills and costs. Overfitting is the one you can test for directly.
The test is simple to state. Choose settings using one block of data, then judge them on a block the choosing never touched. The first block is called in-sample, the second out-of-sample. If performance holds up out-of-sample, the settings captured something durable. If it collapses, you learned that cheaply.
Splitting the chart with date inputs
Pine Script has no built-in train and test split, but it has what you need: input.time gives you a date picker in the settings dialog, and the built-in time variable holds each bar's opening time in milliseconds. Compare the two and you can switch a strategy's entries on only inside a window.
//@version=6
strategy("Windowed MA Cross", overlay = true, initial_capital = 10000,
default_qty_type = strategy.percent_of_equity, default_qty_value = 10,
commission_type = strategy.commission.percent, commission_value = 0.05)
windowStart = input.time(timestamp("1 Jan 2022 00:00 +0000"), "Window start")
windowEnd = input.time(timestamp("1 Jan 2023 00:00 +0000"), "Window end")
fastLen = input.int(10, "Fast length", minval = 2)
slowLen = input.int(30, "Slow length", minval = 3)
inWindow = time >= windowStart and time < windowEnd
fast = ta.sma(close, fastLen)
slow = ta.sma(close, slowLen)
if inWindow and ta.crossover(fast, slow)
strategy.entry("Long", strategy.long)
if ta.crossunder(fast, slow) or not inWindow
strategy.close("Long", comment = "exit")
bgcolor(inWindow ? color.new(color.blue, 92) : na)
plot(fast, "Fast", color.orange)
plot(slow, "Slow", color.teal)Two details matter here. First, the exit condition includes not inWindow, so any trade still open when the window ends is closed on that bar. Without it, a position opened late in the window would carry into the held-back period and its result would leak across the boundary. Second, the background tint shows you exactly which bars are inside the window, which saves a lot of confusion when you move the dates.
To use it, set the window to your in-sample period, tune fastLen and slowLen while watching the Strategy Tester, and write the winning pair down. Then move the window to the next block without touching the lengths and read the result again. That is the whole idea in its simplest form. The weakness is that you get exactly one out-of-sample reading, and one reading can be luck.
Rolling the window forward in folds
Walk-forward testing repeats the split several times. You fit on a training block, test on the block right after it, then slide everything forward and do it again. Each test block is data the matching fit never saw, and together the test blocks form a chain of honest readings instead of one.
With a start date, a training length, and a test length, the windows follow from arithmetic. Fold zero trains on the first trainDays and tests on the next testDays. Fold one starts testDays later, and so on. A fold input lets you step through them in the settings dialog.
//@version=6
strategy("Walk-Forward Folds", overlay = true, initial_capital = 10000,
default_qty_type = strategy.percent_of_equity, default_qty_value = 10,
commission_type = strategy.commission.percent, commission_value = 0.05)
startDate = input.time(timestamp("1 Jan 2021 00:00 +0000"), "First bar of fold 0")
trainDays = input.int(180, "Training days", minval = 30)
testDays = input.int(60, "Test days", minval = 10)
fold = input.int(0, "Fold", minval = 0, maxval = 20)
showTest = input.bool(false, "Trade the test block instead of the training block")
fastLen = input.int(10, "Fast length", minval = 2)
slowLen = input.int(30, "Slow length", minval = 3)
msPerDay = 24 * 60 * 60 * 1000
trainStart = startDate + fold * testDays * msPerDay
trainEnd = trainStart + trainDays * msPerDay
testEnd = trainEnd + testDays * msPerDay
inTrain = time >= trainStart and time < trainEnd
inTest = time >= trainEnd and time < testEnd
active = showTest ? inTest : inTrain
fast = ta.sma(close, fastLen)
slow = ta.sma(close, slowLen)
if active and ta.crossover(fast, slow)
strategy.entry("Long", strategy.long)
if ta.crossunder(fast, slow) or not active
strategy.close("Long", comment = "exit")
bgcolor(inTrain ? color.new(color.blue, 90) : inTest ? color.new(color.orange, 85) : na)
plot(fast, "Fast", color.orange)
plot(slow, "Slow", color.teal)The workflow is a loop. With showTest off, tune the two lengths on fold 0's training block and note the best pair. Switch showTest on, keep the same lengths, and read the Strategy Tester for the test block. Then bump fold to 1 and repeat, tuning again from scratch on the new training block. After five or six folds you have five or six training results paired with five or six test results.
The window lengths deserve thought. A training block needs enough trades to mean something. A rough floor is 30 trades, and 50 to 100 is better, so a slow strategy on a daily chart needs a long block. The test block can be shorter, but a test block with three trades is a coin flip you should not read anything into. Match the lengths to how often the strategy actually trades, not to what is convenient.
Reading both numbers in one run
Switching back and forth gets tedious, and it invites the mistake of forgetting which block you were looking at. A better setup runs the whole history once and reports the in-sample and out-of-sample results side by side. Pine Script can do this with strategy.netprofit, which holds the cumulative closed-trade result, and a table drawn on the last bar.
//@version=6
strategy("Split Report", overlay = true, initial_capital = 10000,
default_qty_type = strategy.percent_of_equity, default_qty_value = 10,
commission_type = strategy.commission.percent, commission_value = 0.05)
splitTime = input.time(timestamp("1 Jan 2023 00:00 +0000"), "Out-of-sample starts")
fastLen = input.int(10, "Fast length", minval = 2)
slowLen = input.int(30, "Slow length", minval = 3)
fast = ta.sma(close, fastLen)
slow = ta.sma(close, slowLen)
if ta.crossover(fast, slow)
strategy.entry("Long", strategy.long)
if ta.crossunder(fast, slow)
strategy.close("Long", comment = "exit")
var float resultAtSplit = na
var int tradesAtSplit = 0
if na(resultAtSplit) and time >= splitTime
resultAtSplit := strategy.netprofit
tradesAtSplit := strategy.closedtrades
if barstate.islast
float inSample = na(resultAtSplit) ? strategy.netprofit : resultAtSplit
float outSample = na(resultAtSplit) ? na : strategy.netprofit - resultAtSplit
int outTrades = strategy.closedtrades - tradesAtSplit
var table t = table.new(position.top_right, 3, 3, border_width = 1)
table.cell(t, 0, 0, "Block", text_color = color.white, bgcolor = color.gray)
table.cell(t, 1, 0, "Net result", text_color = color.white, bgcolor = color.gray)
table.cell(t, 2, 0, "Closed trades", text_color = color.white, bgcolor = color.gray)
table.cell(t, 0, 1, "In-sample")
table.cell(t, 1, 1, str.tostring(inSample, "#.##"))
table.cell(t, 2, 1, str.tostring(na(resultAtSplit) ? strategy.closedtrades : tradesAtSplit))
table.cell(t, 0, 2, "Out-of-sample")
table.cell(t, 1, 2, na(outSample) ? "n/a" : str.tostring(outSample, "#.##"))
table.cell(t, 2, 2, na(resultAtSplit) ? "n/a" : str.tostring(outTrades))One limitation is worth knowing. A position that is open across the split date is credited to whichever block closes it, so the boundary is a little blurry. For a strategy that holds trades for a few bars this is minor. For one that holds for weeks, prefer the fold version above, which force-closes at the edge. Our post on Pine Script Strategy Tester metrics explains what each of the numbers in the Strategy Tester panel measures if you want more than net result.
How to read the comparison
The temptation is to look at whether the out-of-sample number is positive. A better question is how it compares with the in-sample number once you account for the length of each block. If the training block returned a tidy result and the test block returned a fraction of it per trade, the settings were fitted. If both blocks are roughly in the same range, with the test block somewhat worse, that is the normal and healthy pattern, because some degradation is expected from any selection.
Across folds, look at consistency rather than the best fold. A strategy that is acceptable in four of six test blocks and poor in two is telling you something real about how it behaves across regimes. A strategy that is excellent in one test block and poor in five is telling you the one good block was luck. Per-trade averages and drawdown matter here too. Our maximum drawdown guide and the expectancy explainer show how to read those without fooling yourself on small samples.
Resist the urge to go back and retune after seeing a bad test block. The moment you adjust settings in response to the test result, that block becomes part of the fitting data and it stops being a test. If you do want to iterate, hold back a final block you never look at until you are done. It is a bit like not reading the exam before you study.
Common ways walk-forward goes wrong
The most common error is peeking, as described above. The second is blocks that are too small, which produces results dominated by one or two trades. The third is ignoring costs: the strategies above set a commission percentage, and leaving that out inflates every block equally, which can hide how thin a result is. Our guide to slippage and commission shows how fast the costs add up.
A fourth, quieter problem is lookahead inside the indicator itself. If your signals use higher timeframe data fetched carelessly, both blocks are contaminated and a split cannot rescue you. Read how lookahead behaves in Pine Script before trusting any result from a script that calls request.security.
Finally, walk-forward testing reduces the risk of fitting noise. It does not remove it, and it says nothing about the future. Markets change, and a strategy that held up across six folds can still behave differently next quarter. Treat the result as evidence about the script's past behavior, nothing more.
Letting the editor add the scaffolding
Most people never run this test because adding the window logic to an existing script is fiddly. You have to thread date inputs through every entry, make sure exits respect the boundary, and avoid breaking what already works. This is exactly the kind of change where a describe-and-edit workflow helps. In the PineScripter editor you can paste a strategy and ask, in plain English, for in-sample and out-of-sample date inputs that gate the entries. It checks the result against TradingView's compile rules, fixes its own errors, and edits only the lines that change, with a line-by-line view of what moved. You still run the test in TradingView's Strategy Tester, because PineScripter does not simulate trades or report returns. It writes the code, you read the numbers.
If you want a place to start, take any strategy you already have, drop in the first code block above, and look at how its behavior changes when you move the window by a few months. The first time a setting that looked robust falls apart on a held-back block is the moment this habit pays for itself.
Frequently asked questions
What is walk-forward testing in Pine Script?
It is a way to check a strategy against data its settings were not chosen on. You pick settings on a training window, judge them on the window right after it, then slide both forward and repeat. In Pine Script you build the windows with input.time and the built-in time variable.
Does TradingView have a built-in walk-forward optimizer?
No. The Strategy Tester reports one run over the chart's history, and Pine Script has no built-in optimizer. You create the windows with date inputs and step through folds by changing an input, as shown in this guide.
How long should the training and test windows be?
Long enough to hold a meaningful number of trades. A rough floor is 30 trades per block, and 50 to 100 is better. A test block with only a few trades tells you almost nothing, so size windows by trade count, not by calendar convenience.
Why close open trades at the end of a window?
A position opened late in the training window would otherwise carry into the test window, and its result would be counted there. Closing at the boundary keeps each block's result separate.
Does a good out-of-sample result mean the strategy will work live?
No. It lowers the risk that the settings were fitted to noise, but markets change and costs and fills differ live. Treat it as evidence about past behavior only.
Can PineScripter run the walk-forward test for me?
PineScripter writes and edits the Pine Script, including date-window inputs, and checks it against compile rules. It does not simulate trades or report returns, so you run the test in TradingView's Strategy Tester.
Describe your indicator in plain English and get Pine Script that compiles →
Disclaimer: PineScripter is a coding tool for Pine Script development. It does not provide financial advice and does not guarantee trading profits. Always backtest strategies thoroughly and understand the risks before live trading.