trading4 Min Read

The Moving-Average Crossover, Backtested Honestly in Python

Gorav Singal

October 30, 2026

TL;DR

The moving-average crossover is the canonical first strategy: go long when a fast SMA (20-day) crosses above a slow SMA (50-day), go flat when it crosses below. It's the perfect way to learn the backtest loop — signal, shift by one bar to avoid look-ahead, multiply by returns, score it. The full vectorised pandas code is here. But the honest result matters more than the mechanics: on a 6-year synthetic test the crossover LOST to simply buying and holding (CAGR −3.6% vs +6.9%, Sharpe −0.18 vs 0.45), and net of 5 bps costs it was worse. Why? It WHIPSAWS in choppy markets — crossing back and forth, buying near tops and selling near bottoms. A crossover is a trend FILTER, not an edge. Learn the loop from it; don't trade it blindly.

The Moving-Average Crossover, Backtested Honestly in Python

If algo trading has a “hello world,” it’s the moving-average crossover. Every tutorial starts here, and for good reason: it teaches the entire backtest loop in a dozen lines. But most tutorials stop at “look, it makes money!” and quietly cherry-pick the chart. This one shows you the clean code and the honest result — which is more useful than another rigged equity curve.

A note up front: educational material, not financial advice. Nothing here is a recommendation to trade.

The idea

Take two moving averages of the price: a fast one (say 20 days) that reacts quickly, and a slow one (50 days) that reacts slowly. The rule:

  • When the fast SMA crosses above the slow SMA, the recent trend is up → go long.
  • When it crosses below, the trend has turned → go flat (sell, sit in cash).

Moving-average crossover: price with a fast SMA(20) and slow SMA(50); buy when the fast crosses above the slow, sell when it crosses below, otherwise flat. An honest backtest table shows buy-and-hold beating the crossover, which whipsaws in choppy markets.

That’s the whole strategy. It’s intuitive — “ride the uptrends, step aside in downtrends” — which is exactly why it’s the first thing everyone builds.

The backtest, in full

Here’s a complete, runnable vectorised backtest in pandas. Swap the data line for any source from the market-data article.

import numpy as np
import pandas as pd
import yfinance as yf

# 1. Data — adjusted daily closes
px = yf.download("RELIANCE.NS", start="2019-01-01", auto_adjust=True)["Close"]
ret = px.pct_change().fillna(0)

# 2. Signal — fast vs slow SMA
fast = px.rolling(20).mean()
slow = px.rolling(50).mean()
# long (1) when fast > slow, else flat (0). SHIFT by 1 bar: you can only act on
# yesterday's signal at today's open — this avoids look-ahead bias.
signal = (fast > slow).astype(int).shift(1).fillna(0)

# 3. Strategy returns, gross and net of costs
strat_gross = signal * ret
turns = signal.diff().abs().fillna(0)          # 1 whenever we enter or exit
cost  = turns * 0.0005                          # 5 bps per trade (round-turn-ish)
strat_net = strat_gross - cost

# 4. Score it
def metrics(r, ppy=252):
    eq = (1 + r).cumprod()
    cagr = eq.iloc[-1] ** (ppy / len(r)) - 1
    sharpe = r.mean() / r.std() * np.sqrt(ppy)
    maxdd = (eq / eq.cummax() - 1).min()
    return cagr, sharpe, maxdd

for name, r in [("buy & hold", ret), ("MA gross", strat_gross), ("MA net", strat_net)]:
    cagr, sharpe, dd = metrics(r)
    print(f"{name:12} CAGR {cagr*100:6.1f}%  Sharpe {sharpe:5.2f}  maxDD {dd*100:6.1f}%")

The one line that separates a real backtest from a fantasy is .shift(1) on the signal: you can only trade on a signal after you’ve seen it, so you lag it by a bar. Forget it and you “trade” on information you didn’t have yet — the classic look-ahead bias that makes any strategy look brilliant.

The honest result

On a 6-year test (the numbers below are from a synthetic series with two crashes, so you can reproduce the shape of the result; real data will differ in the specifics but rarely in the lesson):

strategy CAGR Sharpe max drawdown
buy & hold +6.9% 0.45 −30.5%
MA crossover (gross) −3.6% −0.18 −46.2%
MA crossover (net, 5 bps) −3.9% −0.21 −46.8%

The crossover lost to simply buying and holding — lower return, negative Sharpe, and a worse drawdown. Costs made it slightly worse still. This is not a bug in the code; it’s the honest, typical outcome.

Why it loses — and what it’s actually for

The crossover’s weakness is whipsaw. In a choppy, sideways market the two averages cross back and forth repeatedly, and each time you buy just as the price turns down and sell just as it turns back up — buying tops, selling bottoms, bleeding a little on every round. It only shines in strong, sustained trends; the rest of the time it chops you up. It was in the market only ~55% of the time here, and the timing of those entries and exits hurt more than it helped.

The real lesson: a moving-average crossover is a trend filter, not an edge. It tells you “the trend is up” or “the trend is down” — useful context — but by itself it is not a reason to expect profit. Real strategies combine such filters with an actual source of edge (a mispricing, a structural quirk, a behavioural pattern) and, crucially, with risk and position sizing and good execution.

What the crossover is perfect for is learning the backtest loop: data → signal → lag it → returns → metrics. Master that pattern here and you can test any idea. Common variations you’ll meet — EMA (exponential) instead of SMA, the “golden cross” (50/200), adding a trend filter to another strategy — are all the same loop with different signal lines.

Takeaways

  • The SMA crossover is the ideal first backtest: go long when fast > slow, flat otherwise.
  • Always .shift(1) the signal — trading on information you didn’t have yet is the #1 way to fool yourself.
  • Honestly tested, it often loses to buy-and-hold because it whipsaws in non-trending markets. A crossover is a filter, not an edge.
  • Score every strategy on CAGR, Sharpe, and max drawdown — and be suspicious of any backtest that looks too good.

Next: a strategy with a genuine, market-neutral edge — mean reversion and pairs trading — and why trading the spread beats betting on direction. For the deep dive on not fooling yourself, see the backtesting lesson in the trading course.

Share

Related Posts

Market Data: Types & Where to Download It (Free and Paid)

Market Data: Types & Where to Download It (Free and Paid)

Data is the raw material of every strategy, and getting the right data is the…

Momentum & Trend Following in Python

Momentum & Trend Following in Python

Momentum is, along with value, one of the most robust and widely-documented…

Execution Algorithms: VWAP & TWAP in Python

Execution Algorithms: VWAP & TWAP in Python

Every strategy so far produced signals — when to be long, short, or flat. But a…

Position Sizing & the Kelly Criterion in Python

Position Sizing & the Kelly Criterion in Python

Every strategy article so far asked ”what should I trade?” This one asks the…

Mean Reversion & Pairs Trading in Python

Mean Reversion & Pairs Trading in Python

The moving-average crossover bet on direction and lost. Here’s a strategy with a…

Market Making Basics: Spread Capture vs Inventory Risk (in Python)

Market Making Basics: Spread Capture vs Inventory Risk (in Python)

So far you’ve been a taker — crossing the spread to get filled. A market maker…

Latest Posts

Execution Algorithms: VWAP & TWAP in Python

Execution Algorithms: VWAP & TWAP in Python

Every strategy so far produced signals — when to be long, short, or flat. But a…

Position Sizing & the Kelly Criterion in Python

Position Sizing & the Kelly Criterion in Python

Every strategy article so far asked ”what should I trade?” This one asks the…

Momentum & Trend Following in Python

Momentum & Trend Following in Python

Momentum is, along with value, one of the most robust and widely-documented…

Mean Reversion & Pairs Trading in Python

Mean Reversion & Pairs Trading in Python

The moving-average crossover bet on direction and lost. Here’s a strategy with a…

Market Making Basics: Spread Capture vs Inventory Risk (in Python)

Market Making Basics: Spread Capture vs Inventory Risk (in Python)

So far you’ve been a taker — crossing the spread to get filled. A market maker…

Order-Book Imbalance: A Microstructure Signal (in Python)

Order-Book Imbalance: A Microstructure Signal (in Python)

The strategies so far used daily or minute bars. This last one goes down to the…