SMA Crossover Backtest in Depth: Fees, Parameter Sweep, Overfitting Risk
This walkthrough extends the hero script with two additions that every serious backtest needs: a fee sensitivity analysis (how much do costs actually matter?) and a parameter sweep across three window combinations (how stable is the result?). The sweep output is the most important lesson on this page: the same dataset gives results from +106.6% to +134.4% depending purely on which windows you pick. That spread is not a signal. It is the overfitting risk, quantified.
"""AlgoDrill — SMA crossover backtest in depth with vectorbt.
Extends the hero script: fee sensitivity + a 3-combo parameter sweep
to illustrate why a single run proves nothing. Backtest only — no broker.
Versions: vectorbt 1.0.0 · yfinance 1.4.1 · pandas 2.3.3 (run 2026-06-05)
Install: pip install vectorbt yfinance
"""
import vectorbt as vbt
# 1. Data — same fixed window as the hero script (reproducible)
price = vbt.YFData.download(
"SPY", start="2015-01-01", end="2024-12-31"
).get("Close")
# 2. Base run: 10/50 SMA crossover, 5 bps fees each side
fast = vbt.MA.run(price, 10)
slow = vbt.MA.run(price, 50)
entries = fast.ma_crossed_above(slow) # golden cross → buy
exits = fast.ma_crossed_below(slow) # death cross → sell
pf = vbt.Portfolio.from_signals(
price, entries, exits, init_cash=10_000, fees=0.0005, freq="d"
)
print("=== Base run: fast=10, slow=50, fees=5 bps ===")
print(f"Total Return {pf.total_return():>8.1%}")
print(f"Sharpe Ratio {pf.sharpe_ratio():>8.2f}")
print(f"Max Drawdown {pf.max_drawdown():>8.1%}")
print(f"Trades {pf.trades.count():>5}")
print(f"Win Rate {pf.trades.win_rate():>8.1%}")
print()
# 3. Buy-and-hold benchmark (same period, no fees)
bh = vbt.Portfolio.from_holding(price, init_cash=10_000, fees=0.0, freq="d")
print(f"Buy & Hold: {bh.total_return():.1%} return (Sharpe {bh.sharpe_ratio():.2f})")
print()
# 4. Fee sensitivity — same parameters, 0 vs 5 vs 20 bps
print("=== Fee sensitivity (fast=10, slow=50) ===")
for bps in (0, 5, 20):
p = vbt.Portfolio.from_signals(
price, entries, exits, init_cash=10_000, fees=bps / 10_000, freq="d"
)
print(f" {bps:>2} bps Return {p.total_return():>7.1%} Sharpe {p.sharpe_ratio():.2f} MaxDD {p.max_drawdown():.1%}")
print()
# 5. Parameter sweep — 3 fast/slow combos to illustrate sensitivity
# WARNING: cherry-picking the best row here is exactly how backtest bias works.
print("=== Parameter sweep (same data, different windows) ===")
combos = [(5, 20), (10, 50), (20, 100)]
for f_w, s_w in combos:
fa = vbt.MA.run(price, f_w)
sl = vbt.MA.run(price, s_w)
en = fa.ma_crossed_above(sl)
ex = fa.ma_crossed_below(sl)
p = vbt.Portfolio.from_signals(
price, en, ex, init_cash=10_000, fees=0.0005, freq="d"
)
print(
f" fast={f_w:>2}/slow={s_w:>3}"
f" Return {p.total_return():>7.1%}"
f" Sharpe {p.sharpe_ratio():.2f}"
f" MaxDD {p.max_drawdown():.1%}"
f" Trades {p.trades.count():>3}"
)Script Output
=== Base run: fast=10, slow=50, fees=5 bps ===
Total Return 106.6%
Sharpe Ratio 0.87
Max Drawdown -15.1%
Trades 30
Win Rate 46.7%
Buy & Hold: 240.8% return (Sharpe 0.95)
=== Fee sensitivity (fast=10, slow=50) ===
0 bps Return 112.8% Sharpe 0.90 MaxDD -14.9%
5 bps Return 106.6% Sharpe 0.87 MaxDD -15.1%
20 bps Return 89.1% Sharpe 0.77 MaxDD -15.6%
=== Parameter sweep (same data, different windows) ===
fast= 5/slow= 20 Return 134.4% Sharpe 1.03 MaxDD -19.2% Trades 67
fast=10/slow= 50 Return 106.6% Sharpe 0.87 MaxDD -15.1% Trades 30
fast=20/slow=100 Return 111.7% Sharpe 0.81 MaxDD -22.0% Trades 12
Reading the Results
Base run and buy-and-hold benchmark
The base run (fast=10, slow=50, 5 bps fees) reproduces the hero script: +106.6% return, 0.87 Sharpe, −15.1% max drawdown, 30 trades, 46.7% win rate. The buy-and-hold benchmark is essential context: the same SPY position held from 2015-01-01 to 2024-12-31 with no trading returned +240.8% with a Sharpe of 0.95. The SMA strategy underperforms on return and on risk-adjusted return, but reduces max drawdown.
Fee sensitivity: 0 vs 5 vs 20 bps
Fees are not a cosmetic assumption. Moving from 0 to 20 bps per side drops the return from +112.8% to +89.1% — a 24-point swing — and the Sharpe from 0.90 to 0.77. For the 10/50 combination with 30 trades, the cumulative fee burden over 10 years is manageable. For the 5/20 combination with 67 trades, the same fee increase would be more damaging (roughly 2.2× more trades, 2.2× more cost).
The lesson: always run your backtest at realistic fees, not zero. Zero-fee backtests overstate performance and misrepresent the impact of trading frequency. Retail US equity commissions are now effectively $0 at most brokers, but spread and market impact are not zero, especially for less liquid instruments or larger position sizes. The 5 bps assumption is conservative for liquid ETFs but reasonable as a lower bound.
Parameter sweep: the overfitting risk in numbers
The three-combo sweep — fast=5/slow=20, fast=10/slow=50, fast=20/slow=100 — is not a comprehensive search. It is a minimal illustration of sensitivity. The results range from +106.6% to +134.4% return, from Sharpe 0.81 to 1.03, and from 12 to 67 trades, all on the same historical dataset.
Cherry-picking the winner is overfitting. The 5/20 combination produced the best return and Sharpe in this window. If you select 5/20 based on this backtest and trade it forward, you are selecting a parameter that fit a specific historical path — not a parameter that has an edge in the underlying price process. The probability that the best in-sample parameter ranks first out-of-sample is only slightly better than chance for noisy financial data. This is the core problem addressed by walk-forward analysis.
The spread between best and worst is 27.8 percentage points on return and 0.22 on Sharpe. For context: the difference between a strategy you trade and one you don't is often measured in Sharpe differences of 0.1–0.3. A 0.22-Sharpe swing from parameter choice alone means the in-sample rankings are unreliable for selecting which system to trade.
What to Do After the Sweep
The correct use of a parameter sweep is not to pick the best row. It is to:
- Understand sensitivity: If every combination produces similar results, the strategy is robust to the parameter choice. If results scatter widely (as above), the parameter is a model degree of freedom that the optimizer can fit to noise.
- Pre-commit before running: Define your hypothesis about which parameter direction should work (longer windows = fewer signals = less whipsaw) before running the sweep. Post-hoc rationalization of results is a form of data snooping.
- Validate out-of-sample: Reserve a period of data the sweep never sees. Run walk-forward analysis: train on rolling windows, test on the next period, and measure whether performance persists. See Walk-Forward Analysis for the methodology and Backtesting Pitfalls for the seven failure modes this addresses.
Run It as a Notebook
This walkthrough is also a Jupyter notebook in the public algodrill-notebooks repo — same code, same pinned versions, plus an overlay chart of the three parameter combos’ equity curves the text walkthrough doesn’t show. Open it in Google Colab and it runs in your browser with zero local setup.
=== Base run: fast=10, slow=50, fees=5 bps ===
Total Return 106.6%
Sharpe Ratio 0.87
Max Drawdown -15.1%
Trades 30
Win Rate 46.7%
Buy & Hold: 240.8% return (Sharpe 0.95)
=== Fee sensitivity (fast=10, slow=50) ===
0 bps Return 112.8% Sharpe 0.90 MaxDD -14.9%
5 bps Return 106.6% Sharpe 0.87 MaxDD -15.1%
20 bps Return 89.1% Sharpe 0.77 MaxDD -15.6%
=== Parameter sweep (same data, different windows) ===
fast= 5/slow= 20 Return 134.4% Sharpe 1.03 MaxDD -19.2% Trades 67
fast=10/slow= 50 Return 106.6% Sharpe 0.87 MaxDD -15.1% Trades 30
fast=20/slow=100 Return 111.7% Sharpe 0.81 MaxDD -22.0% Trades 12Same numbers as the script above, captured from an independent run of the notebook — two runs, two environments, one result.
Frequently Asked Questions
- Why does changing SMA windows change the backtest result so much?
- The three window combinations (5/20, 10/50, 20/100) on the same SPY 2015-2024 data produced returns of +134.4%, +106.6%, and +111.7% respectively. Each combination trades the same underlying price series, but different crossover points produce different entry and exit signals — different trades, different holding periods, different P&L. The 28-percentage-point spread between best and worst is not signal; it is noise from fitting a binary parameter choice to one historical path. A proper walk-forward analysis holds out a future window and tests whether the winning parameters outperform on data they have not seen. See the walk-forward analysis page for the methodology.
- What is the effect of transaction fees on a backtest?
- At 0 bps (zero cost): return +112.8%, Sharpe 0.90. At 5 bps per side: return +106.6%, Sharpe 0.87. At 20 bps per side: return +89.1%, Sharpe 0.77. The 10/50 SMA crossover made 30 trades over 10 years. At 20 bps per side, each round trip costs 40 bps on capital deployed, and 30 round trips cost approximately 12% in cumulative fees. Strategies with more trades (like the 5/20 combination with 67 trades) are far more sensitive to fee assumptions — at 20 bps, the 5/20 strategy's lead over 10/50 would evaporate.
- What is a vectorized backtest and why might it be optimistic?
- A vectorized backtest computes all signals and positions across the full historical price array simultaneously, using array operations. vectorbt is a vectorized engine. The main risk is look-ahead bias: naive vectorized code can accidentally reference future prices when computing signals (e.g., using a rolling calculation that includes the current bar's close in the signal for the current bar's entry). vectorbt's from_signals() avoids this for entry/exit signals, but any preprocessing you do to the price series before passing it in must be manually reviewed for look-ahead. Event-driven engines (QuantConnect/LEAN, NautilusTrader) structurally prevent look-ahead by stepping through one bar at a time — but they are slower for parameter sweeps. See the event-driven vs vectorized comparison for a full breakdown.
- How do I choose which SMA window combination to use?
- Do not choose based on in-sample backtest performance. The 5/20 combination appears best (+134.4%) in the 2015-2024 window, but this does not mean it is best in general — it means it fit this particular historical path better than the others, which is the definition of overfitting. The correct approach: (1) pre-specify a small set of hypotheses from first principles before looking at results (e.g., 'I expect longer windows to reduce whipsaws at the cost of slower entry'); (2) run the sweep; (3) validate on an out-of-sample window using walk-forward analysis. If the parameter's rank order flips on out-of-sample data, the in-sample ranking was noise.
- Is there a Jupyter notebook version of this walkthrough?
- Yes. The same code runs as a notebook in the public algodrill-notebooks GitHub repo (github.com/mindgaptech/algodrill-notebooks), with an Open in Colab badge for running it in-browser with no local setup. It uses the identical pinned versions (vectorbt 1.0.0, yfinance 1.4.1, pandas 2.3.3) and reproduces the same base run, fee sensitivity, and parameter sweep numbers, plus an overlay chart of the three combos' equity curves the text walkthrough does not show.
See why the in-sample ranking of these parameters probably will not hold out-of-sample — and the validation framework that addresses it.
Backtesting Pitfalls → Event-Driven vs Vectorized →