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.
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.
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 →