Most backtests you see online were optimized on the same data they report. Someone tries 50 settings, keeps the best one, and shows you its equity curve. That curve is the answer to "which settings fit the past best", not "what would this strategy have earned". Walk-forward analysis is the standard way to get the second answer.
We ran one from start to finish on Bitcoin so the numbers in this guide are real. The short version: the settings that looked best in each training period averaged a Sharpe ratio of 1.44. The same settings, traded on the next six months they had never seen, averaged 0.22.
The vocabulary
| Term | What it means |
|---|---|
| In-sample (IS) | The data you use to choose rules and parameters |
| Out-of-sample (OOS) | Data the choice never saw, used only to measure the result |
| Walk-forward | Repeating IS then OOS through time: optimize, trade the next block, move forward, repeat |
| Rolling window | The IS window keeps a fixed length and slides forward |
| Anchored window | The IS window always starts at the beginning and grows |
| Walk-forward efficiency | Out-of-sample return divided by in-sample return, both annualized |
A single split (optimize on 2019 to 2023, test on 2024 to 2026) is the simplest out-of-sample test. Walk-forward does it many times, so you see how the process of choosing parameters holds up, not just one lucky or unlucky split.
Why the in-sample result is always too good
When you pick the best of N settings, you pick the one that combines whatever edge exists with the most luck. Even with no edge at all, the best of many tries looks good. On three years of daily data, the best of 100 strategies with no edge is expected to show an annualized Sharpe ratio of about 1.46. The deflated Sharpe ratio calculator works this out for any number of tries and any backtest length.
Out-of-sample data removes the luck from the selection, because the choice was made before that data existed for you. What remains is closer to what you'd have earned.
The experiment
| Market | BTCUSDT spot, Binance, daily candles |
| Data | 1 Jan 2018 to 31 Oct 2026, 3,226 days, no gaps |
| Strategy | Long when the fast simple moving average is above the slow one, otherwise in cash |
| Execution | Decided on the daily close, held from the next day; no look-ahead |
| Costs | 0.10% per side on every change of position |
| Settings tried | Fast 5, 10, 20, 30, 40 × slow 50, 100, 150, 200, 250 = 25 combinations |
| Selection rule | Highest in-sample Sharpe ratio (annualized over 365 days) |
| Windows | 24 months in-sample, the next 6 months out-of-sample, rolled forward every 6 months |
| Out-of-sample span | 1 Jan 2021 to 31 Oct 2026: 12 windows, 2,130 days |
Moving averages aren't special here. They're simple enough that the result can't hide behind complexity, and they're what most people try first.
What happened window by window
| Out-of-sample period | Chosen (fast/slow) | IS Sharpe | OOS Sharpe | Rank out of 25, OOS |
|---|---|---|---|---|
| 2021 H1 | 30 / 50 | 2.11 | 2.16 | 2 |
| 2021 H2 | 30 / 50 | 1.72 | 0.69 | 5 |
| 2022 H1 | 5 / 50 | 2.03 | −2.52 | 25 |
| 2022 H2 | 10 / 100 | 1.88 | −1.63 | 20 |
| 2023 H1 | 5 / 150 | 0.76 | 1.61 | 8 |
| 2023 H2 | 5 / 150 | 0.77 | 1.52 | 9 |
| 2024 H1 | 5 / 200 | 1.19 | 1.72 | 4 |
| 2024 H2 | 5 / 200 | 1.46 | −0.01 | 25 |
| 2025 H1 | 5 / 50 | 1.91 | 0.55 | 12 |
| 2025 H2 | 40 / 50 | 1.58 | −1.10 | 23 |
| 2026 H1 | 5 / 100 | 1.18 | −1.05 | 13 |
| 2026 H2 (to 31 Oct) | 5 / 100 | 0.66 | 0.75 | 6 |
Three things stand out. The chosen setting changed seven times in twelve windows, so "the best parameters" weren't stable. Twice, the in-sample winner was the worst of all 25 settings in the next six months. And the in-sample Sharpe ratio told you almost nothing about the next period: the two highest in-sample values (2.11 and 2.03) were followed by the best and the worst out-of-sample results.
The stitched out-of-sample result
Chain the twelve out-of-sample periods together and you get the equity curve you could actually have traded, switching costs included:
| 1 Jan 2021 to 31 Oct 2026 | Walk-forward | Buy and hold | Best setting in hindsight (5/100) |
|---|---|---|---|
| Sharpe ratio | 0.67 | 0.58 | 0.94 |
| Annual return | 20.8% | 18.4% | 34.6% |
| Maximum drawdown | −51.3% | −76.6% | −41.2% |
| Total return | +201.6% | +168.2% | +465.9% |
The last column is what a single optimization over the whole period would have shown you: one setting, picked after the fact, with a Sharpe ratio of 0.94. That's the number a typical backtest screenshot reports. The tradeable version earned 20.8% a year, not 34.6%.
Walk-forward efficiency, the out-of-sample annual return divided by the average in-sample annual return of the chosen settings (82.1%), comes out at 0.25. Three quarters of the in-sample performance was selection.
Reading it honestly
The walk-forward version did a bit better than holding Bitcoin on a risk-adjusted basis, mostly because it sat in cash during part of 2022 and kept the drawdown to −51% instead of −77%. That's a real effect of trend filters in long bear markets. But it isn't proof of an edge:
- Twelve windows and 23 entries in nearly six years is a small sample.
- The hindsight winner, run through the deflated Sharpe ratio with 25 tries, 2,130 daily returns, skewness 0.74 and kurtosis 13.1, gives a luck Sharpe of 0.83 and a deflated Sharpe ratio of 61%. As a single test it would look 98.9% significant. Accounting for the search, you can't tell it from luck.
- To pass at 95% after 25 tries at that Sharpe ratio, the same calculator says you'd need about 15 years of data.
The useful conclusion is about the process: if you'd optimized and trusted the result, you'd have expected a Sharpe near 1.4 and got about a sixth of it in the periods that followed.
Run your own walk-forward
- Freeze the rules first. The indicator, the parameter grid, the costs and the selection rule (highest Sharpe, highest profit factor with a minimum number of trades…) are decided before you look at any result.
- Pick window lengths that fit the strategy. Each in-sample window needs enough trades that one lucky trade doesn't decide the choice. A daily trend system needs years; an intraday one may need weeks.
- Optimize on the in-sample window only, then trade the chosen setting, unchanged, on the next out-of-sample window.
- Roll forward and repeat until the end of the data.
- Stitch the out-of-sample results and judge only that curve, against a simple benchmark such as buy and hold.
- Count every try, including grids you ran and dropped. That count is what the deflated Sharpe ratio needs.
- Keep a final holdout you never touch until the very end, if you plan to iterate on the idea itself.
The code
This is the complete script we ran, in plain Python with no libraries. It reads a CSV with date and close columns (for example Binance BTCUSDT daily closes) and prints the in-sample and out-of-sample Sharpe ratio of every window, then the stitched result.
import csv, math
from datetime import date
FEE = 0.001 # 0.10% per side
FAST = [5, 10, 20, 30, 40]
SLOW = [50, 100, 150, 200, 250]
DAYS_PER_YEAR = 365 # crypto trades every day
rows = list(csv.DictReader(open("btc_daily.csv")))
days = [date.fromisoformat(r["date"]) for r in rows]
close = [float(r["close"]) for r in rows]
ret = [0.0] + [close[i] / close[i - 1] - 1 for i in range(1, len(close))]
def sma(x, n):
out, s = [None] * len(x), 0.0
for i, v in enumerate(x):
s += v - (x[i - n] if i >= n else 0)
if i >= n - 1:
out[i] = s / n
return out
AVG = {n: sma(close, n) for n in FAST + SLOW}
def positions(fast, slow):
"""1 = long, 0 = flat. Decided at day i-1's close, held during day i."""
pos = [0] * len(close)
for i in range(1, len(close)):
f, s = AVG[fast][i - 1], AVG[slow][i - 1]
pos[i] = 1 if f is not None and s is not None and f > s else 0
return pos
def daily_returns(pos, start, end, prev):
out = []
for i in range(start, end):
out.append(pos[i] * ret[i] - abs(pos[i] - prev) * FEE)
prev = pos[i]
return out
def sharpe(r):
mu = sum(r) / len(r)
sd = math.sqrt(sum((x - mu) ** 2 for x in r) / (len(r) - 1))
return mu / sd * math.sqrt(DAYS_PER_YEAR) if sd > 0 else 0.0
def first_index(d):
return next(i for i, x in enumerate(days) if x >= d)
def month_add(d, k):
m = d.month - 1 + k
return date(d.year + m // 12, m % 12 + 1, 1)
POS = {(f, s): positions(f, s) for f in FAST for s in SLOW}
oos, prev = [], 0
start = date(2019, 1, 1)
while month_add(start, 24) < date(2026, 11, 1):
a, b = first_index(start), first_index(month_add(start, 24))
end = month_add(start, 30)
c = first_index(end) if end < date(2026, 11, 1) else len(close)
# pick the best setting on the in-sample window only
best = max(POS, key=lambda k: sharpe(daily_returns(POS[k], a, b, POS[k][a - 1])))
is_sr = sharpe(daily_returns(POS[best], a, b, POS[best][a - 1]))
chunk = daily_returns(POS[best], b, c, prev) # then trade it, untouched, out of sample
prev = POS[best][c - 1]
oos += chunk
print(f"{days[b]} to {days[c - 1]} best {best} in-sample {is_sr:5.2f} out-of-sample {sharpe(chunk):5.2f}")
start = month_add(start, 6)
equity = 1.0
for x in oos:
equity *= 1 + x
print(f"stitched out-of-sample: Sharpe {sharpe(oos):.2f}, total return {equity - 1:.1%}")
The prev variable carries the position from one window into the next, so a change of setting that flips the position pays its fee. Leaving that out is a common way to flatter a walk-forward.
On TradingView and MetaTrader 5
TradingView's Strategy Tester has no walk-forward mode. You can still do it by hand: optimize on a date range, then change the range to the next block and record the result without touching the inputs. Putting the test window in code, as shown in our guide to Strategy Tester settings, makes this repeatable.
MetaTrader 5's Strategy Tester has a Forward setting (1/2, 1/3, 1/4 or a custom date). It splits the chosen period in two, optimizes on the first part and checks the selected parameters on the second, always the most recent segment. That's one split rather than a full walk-forward, but it's built in and worth using on every optimization. Our guide to MT5 Strategy Tester modelling modes covers the other settings that change results.
Sources and further reading
- Bailey, D. H. and López de Prado, M. (2014), "The Deflated Sharpe Ratio: Correcting for Selection Bias, Backtest Overfitting and Non-Normality", The Journal of Portfolio Management 40(5).
- MetaTrader 5 Help: Strategy Tester settings: the Forward option.
- Binance public market data API: the daily candles used here.
Test your own idea this way
Describe a strategy in plain English to the Backtest Lab and it runs with fees and slippage, keeps an out-of-sample period apart, and says plainly when a result could be luck. Our list of backtesting mistakes that inflate results covers what to check before any of this.
This article describes historical simulations for education. It isn't investment advice, and past or simulated results don't predict future returns. Trading involves substantial risk of loss.
Frequently asked questions
What is walk-forward analysis in trading?
It is a way to test a strategy's parameter choices on data they haven't seen. You optimize on a window of past data, trade the chosen settings unchanged on the next window, move both windows forward and repeat. Only the chained out-of-sample results count.
What is the difference between in-sample and out-of-sample?
In-sample data is what you use to choose rules and parameters. Out-of-sample data is kept apart and used only to measure the result. In our Bitcoin example the chosen settings averaged a Sharpe ratio of 1.44 in-sample and 0.22 out-of-sample.
What is a good walk-forward efficiency?
Walk-forward efficiency is the annualized out-of-sample return divided by the annualized in-sample return. There is no universal threshold. A value near 1 means the optimization added little luck; our example came out at 0.25, meaning three quarters of the in-sample performance came from selection.
Rolling or anchored windows?
Rolling windows keep a fixed length and adapt faster to new conditions. Anchored windows keep all past data and change their choice more slowly. Try both if you like, but decide which one you will trust before looking at the results.
Can I do walk-forward testing on TradingView?
Not automatically. You can do it by hand: optimize on one date range, then test the chosen inputs unchanged on the next range, and record each block. MetaTrader 5 has a built-in Forward option that does a single split.