Backtesting an ETF Strategy Without Fooling Yourself

Backtests are not predictions. They are debugging tools for your assumptions. The fastest way to fool yourself is to let the code see the future, hide costs, test one pleasant window, then tune until the equity curve stops embarrassing you.

Freeze the information set

Start with a simple rule: every data point used by a signal must have been available before the trade. That sounds obvious, but lookahead bias sneaks in through adjusted prices, index membership, financial statement dates, and same-bar execution.

Three common leaks:

  • Using today’s close to compute today’s signal and trade at today’s close.
  • Using a current ETF universe list on historical dates. ETFs launch and close. A universe that only contains today’s survivors is a survivorship-biased universe.
  • Joining dividend or holdings data by calendar date instead of publication date.

If your signal is computed at close, execute at next open or next close after a lag. If you use fundamentals, join on the report date, not the period end. In pandas, make the lag explicit:

import pandas as pd

# prices: date, open, close, volume
# signal_raw: date, value (computed from same-day close)
# Enforce one full bar of delay before execution.
df = prices.merge(signal_raw, on='date', how='left')
df['signal'] = df['value'].shift(1)
df['trade_date'] = df['date']

# If you execute at next open, shift the execution price instead:
df['exec_price'] = df['open'].shift(-1)
# And drop the last row, which has no future open.
df = df.dropna(subset=['exec_price'])

# Point-in-time universe: join with listing dates and only keep
# symbols whose listing_date <= date < delisting_date.
df = df.merge(listing_dates, on='symbol')
df = df[(df['date'] >= df['listing_date']) &
        ((df['delisting_date'].isna()) | (df['date'] < df['delisting_date']))]

Also beware adjusted close from vendors. It is usually retroactively adjusted for splits and dividends, which is useful for total return, but if you use it for signal levels and execution prices, you may introduce future information. Keep two series: raw prices for execution and total-return series for performance attribution.

Count dividends and frictions honestly

An ETF strategy that ignores dividends is not conservative; it is wrong in a predictable direction. Many equity ETFs distribute income, and those distributions are part of the return. But dividends are not free money. You must model withholding tax, currency conversion, fund fees, and the fact that the ex-dividend price drop is real.

Trading costs matter even for liquid ETFs. The bid-ask spread, commission, and slippage may be small per trade, but they compound with turnover. A daily strategy with 100% turnover can bleed out through frictions that a monthly strategy barely notices.

Make assumptions explicit in a config file, not buried in code:

backtest:
  start: '2015-01-01'
  end: '2025-12-31'
  execution: 'next_open'
  universe:
    source: 'point_in_time_listings'
    min_history_days: 252
  return_series:
    use_adjusted_close: true
    include_dividends: true
    dividend_tax_rate: 0.21   # example placeholder; set from your tax situation
  costs:
    commission_bps: 5
    spread_bps: 2
    slippage_bps: 3
    min_trade_notional: 1000
  currency:
    base: 'USD'
    fx_cost_bps: 10

The table below is not a prescription. It is a checklist of cost components that should either appear in your config or be consciously set to zero with a reason.

ComponentWhy it mattersTypical place to set it
CommissionDirect cash cost per tradeBroker schedule, per-share or bps
Bid-ask spreadYou cross the spread on entry/exitHalf-spread assumption by liquidity bucket
SlippagePrice moves between signal and fillbps by ADV or volatility
Market impactLarge orders move the pricePosition size relative to ADV
Dividend withholdingReduces net distributionsTax rate by investor domicile
FX conversionCross-border ETF trades need currencybps per conversion
Fund feesOngoing dragExpense ratio, accrued daily

If you cannot estimate a cost, run the backtest with zero and with a deliberately pessimistic number. The difference tells you how fragile the edge is. Never report only the optimistic run.

Test on the whole timeline, not the window that flatters you

The easiest self-deception is choosing a start date after a drawdown or before a bull run. A strategy tested on 2010-2021 looks different from one tested on 2000-2025. You do not need to predict the future, but you do need to stop selecting the past.

Use a validation scheme that matches how you will trade. For most ETF strategies, that means walk-forward analysis: train or select parameters on an in-sample window, trade them on the next out-of-sample window, then roll forward. The out-of-sample segments are the only ones you should believe.

MethodWhat it protects againstMain weakness
Single train/test splitObvious overfittingOne test window can be lucky
Walk-forwardRegime change and parameter driftCan be slow; overlapping data needs care
Purged K-foldLeakage from overlapping labelsComplex for simple ETF rules
Monte Carlo trade shuffleFragile path dependenceNot a substitute for out-of-sample
Parameter sensitivity sweepFragile peak parametersStill in-sample unless nested

A useful sanity check is to run the strategy on the full period with no parameter optimization. If the default parameters fail everywhere except one window, you do not have a strategy. You have a story about that window.

Stop tuning until the curve looks right

Overfitting is not a single mistake. It is a process: you try a parameter, look at the curve, adjust, repeat. By the time the curve looks good, you have trained on the test set without realizing it.

Combat this with pre-registration. Before you run the sweep, write down the parameter ranges, the selection metric, and the maximum number of trials you will allow. Then run a grid and inspect the shape, not just the best cell. A robust parameter should sit on a plateau, where nearby values perform similarly. A single spike surrounded by poor results is a warning.

# Example: run walk-forward with fixed trial budget.
# Do not keep adding trials after seeing the out-of-sample result.
for window in 252 504 756; do
  for lookback in 20 60 120; do
    python backtest.py \
      --start 2005-01-01 \
      --end 2025-12-31 \
      --train-window "$window" \
      --signal-lookback "$lookback" \
      --execution next_open \
      --costs config/costs.yaml \
      --out "runs/wf_${window}_${lookback}.json"
  done
done

# Then compare the distribution of out-of-sample returns,
# not the single best run.
python analyze_runs.py runs/wf_*.json --metric cagr --metric max_drawdown

Other checks:

  • Does the strategy survive if you lag the signal by one extra bar?
  • Does it survive if you double costs?
  • Does it survive if you remove the best five days or the best five trades?
  • Does it survive if you start in a different decade?

If a strategy only works under one assumption, that assumption is the strategy. You should test the assumption directly.

Automation can help you run these checks repeatedly. You can wire data refresh and walk-forward runs into the same kind of pipeline described in the AI automation guide, and use workflow tools like Zapier vs n8n vs AI Workflow Builder to schedule them. But automation does not validate a backtest. It only makes it easier to repeat the same mistakes faster.

What to do next

  1. Write a one-page backtest protocol before you run another optimization. Include your universe source, signal lag, execution rule, cost assumptions, validation scheme, and maximum trial count. Date it.
  2. Re-run your current backtest with a forced one-bar lag and a point-in-time universe. If the results collapse, you had a lookahead or survivorship problem.
  3. Reconcile dividends and costs separately. Produce two reports: one with gross total return and one with net return after your cost config. Keep both.
  4. Reserve a final holdout period and do not touch it until you have frozen the strategy. If you look at it more than once, it is no longer a holdout.
  5. If your current setup is a spreadsheet plus scattered scripts, the ETF Dashboard is a Taiwan-ETF analytics workbench that puts price and dividend data, technical indicators and backtesting in one dashboard for $199 USD one-time. Use it only after you have a protocol; the tool will not choose your assumptions for you.

Get ETF Dashboard

ETF Dashboard โ€” $199, one-time payment, instant download. See the full breakdown on the review page.

About the author
Published by slashman413 โ€” writing practical, evergreen guides on money, productivity, developer tooling and the web. More about this site โ†’

Frequently Asked Questions

What is lookahead bias in ETF backtesting?

It is using information that was not available at the time of the trade, such as same-day closing prices, revised index membership, or restated dividends. The fix is to lag signals and join data on the date it was actually published.

Should I use adjusted close or raw prices in a backtest?

Use raw prices for execution assumptions and an adjusted total-return series for performance measurement. Mixing them can create future information in your signals or hide the cash impact of dividends.

How many parameters can I tune before a backtest is overfit?

There is no safe number; the risk grows with every choice you make after seeing the data. Pre-register a small range, prefer parameter plateaus over spikes, and keep a final holdout you never optimize on.

๐ŸŽ Recommended Tools

๐Ÿ“š Related Articles

๐Ÿ“ฌ Free Weekly AI Product Guides

New tools, templates and automation walkthroughs โ€” plus hands-on updates on the Slashman Tools catalogue. One email a week, zero fluff.

Free forever ยท No spam ยท Unsubscribe anytime ยท Sent instantly

Join Free