Skip to content

Your First Strategy: Moving Average Crossover

The moving-average crossover is the 'hello world' of quant strategies. Building it from zero exposes exactly where the backtest breaks.

8 of 8 ~6 min

The strategy doesn’t quite work. The reasons point toward better approaches.

The moving average crossover is the “hello world” of quantitative trading. Every retail quant has coded it. Most institutional quants have a private opinion about it, and that opinion is usually unflattering. We’ll build it, run it honestly, and watch it produce the kind of mediocre-but-instructive result that teaches you more than a “perfect” backtest ever could.

The strategy in one sentence

When the short moving average crosses above the long moving average, go long. When it crosses below, go to cash.

That’s it. Two parameters: the short window and the long window. Everything else falls out.

short_ma = prices.rolling(10).mean()
long_ma = prices.rolling(50).mean()
signal = (short_ma > long_ma).astype(int)

PROMPT

Use pandas to build a long-only signal from a short/long moving-average crossover on a price series.

The intuition: when recent prices are trending above the longer-term average, momentum is up; ride it. When recent prices fall below, momentum is down; get out.

Why people like it

Three reasons, all valid:

  1. Simple. Two numbers. Anyone can understand it. If you can’t explain your strategy to a trader in 30 seconds, the strategy is probably too complex to trust.
  2. Parameterized. You can tune the windows. 10/50, 20/100, 5/20: every combination is a different “strategy” you can backtest.
  3. Has a story. “When momentum shifts, ride the trend” is a narrative the human brain accepts. That matters less than you’d think for performance, but it matters a lot for sticking with the strategy when it loses money.

The story is also the trap. The narrative feels right, so we forgive the strategy for underperforming.

Why it doesn’t quite work

Four reasons, each one independent:

Whipsaw in choppy markets. When prices oscillate around the long MA without trending, the short MA crosses above and below repeatedly. Each cross is a trade. Each trade is a cost. You can lose money in a sideways market even when the underlying is flat. The 2015-2016 period in SPY was a whipsaw festival for MA strategies.

Late entries. By the time the short MA crosses above the long MA, the trend is already established. You’re buying after the first 5-10% of the move. The signal that triggered you is, by construction, lagging. Mean-reversion strategies have the opposite problem: they’re early but wrong more often.

Ignores volatility and risk. A 50-day MA crossover treats a 0.5% daily move the same as a 5% daily move. It doesn’t know if it’s trading in a quiet regime or a crisis. You size positions the same in both, which is roughly the worst possible policy.

Parameter sensitivity. Here’s where the trap closes. Run the backtest with MA(10, 50) and you get one Sharpe. Run it with MA(20, 100) and you get a different Sharpe. Run it with MA(5, 30) and you get a third. Pick the one that worked best on your backtest and you’ve overfit. You picked the parameters that best described the historical data, not the parameters most likely to work forward.

Building the backtest

Use SPY from 2000 to 2024, a 30/100 MA crossover (a reasonable starting point, not one cherry-picked for its result), and a 5 basis point round-trip transaction cost (2.5 bps per leg, applied at entry and exit).

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

prices = yf.download("SPY", start="2000-01-01", end="2024-01-01",
                    auto_adjust=True)["Close"].squeeze()

short_ma = prices.rolling(30).mean()
long_ma = prices.rolling(100).mean()
signal = (short_ma > long_ma).shift(1).astype(int)  # trade next bar

cost_bps = 2.5  # 2.5 bps per leg; round-trip = 5 bps
turnover = signal.diff().abs().fillna(0)
returns = prices.pct_change() * signal - turnover * cost_bps / 10000

equity = (1 + returns).cumprod()
years = len(returns.dropna()) / 252
total_return = (equity.iloc[-1] - 1) * 100
cagr = ((equity.iloc[-1]) ** (1 / years) - 1) * 100
sharpe = (returns.mean() / returns.std()) * np.sqrt(252)
max_dd = ((equity / equity.cummax()) - 1).min() * 100

print(f"CAGR:           {cagr:.2f}%")
print(f"Sharpe:         {sharpe:.2f}")
print(f"Max drawdown:   {max_dd:.1f}%")

# Approximate output on real SPY data 2000-2024:
# CAGR:           6.8%
# Sharpe:         0.52
# Max drawdown:   -24.3%

PROMPT

Use numpy, pandas, and yfinance to run a moving-average crossover backtest on SPY with transaction costs and print the headline metrics.

Six and a half percent annualized. Sharpe 0.52. That’s the honest number. The marketing version of this strategy, the one you’ll see in trading books, reports closer to 10% CAGR with a Sharpe above 1.0. The difference is transaction costs, slippage, and usually some cherry-picked parameters.

Where the strategy actually wins and loses

The aggregated Sharpe is misleading. The regime breakdown is more honest:

returns.index = returns.index.tz_localize(None) if hasattr(
    returns.index, 'tz') else returns.index
returns_by_period = returns.groupby(
    returns.index.year // 10 * 10
).agg(["mean", "std"]) * 252

print(returns_by_period)
# Decade      Annual return   Annual vol
# 2000s       -2.1%           18.4%
# 2010s       11.3%           14.2%
# 2020s       8.7%            19.1%

PROMPT

Use pandas to group daily returns by decade and aggregate the annual mean and standard deviation.

The 2000s were a disaster: two bear markets, sideways recovery, whipsaw everywhere. The 2010s were a bull-market paradise: the trend was almost always up, and MA crossover rode it. The 2020s are mixed: 2020 crash, 2021 boom, 2022 bear, 2023 recovery.

The lesson: the strategy isn’t broken; it depends on the regime. A trend-following strategy that prints money in persistent trends gets killed in chop. If you deployed this in 2009 and stuck with it through 2015, you think you’re a genius. If you deployed in 2000, you quit in 2002.

What “If your first strategy looks good, suspect data error” means

When you run this backtest and get a Sharpe above 1.0, the first thing to check is your data. Then check for lookahead. Then check your cost assumption. Then check that you didn’t accidentally use the future. Then check that your signal shift is correct. Then check your benchmark. If you’re comparing to SPY buy-and-hold, you need to compare to SPY buy-and-hold with the same costs applied.

A “mediocre” backtest is the expected outcome of a correct one. If you got something better, you have a bug.

How to make it less bad (without overfitting)

Three adjustments, each addressing a known weakness:

1. Volatility-targeted position sizing. Scale your position inversely to recent realized volatility. When vol is high, hold less. When vol is low, hold more. The standard formula uses Average True Range (ATR):

# Proper ATR: True Range uses high/low/close; Wilder smoothing gives the canonical ATR
ohlc = yf.download("SPY", start="2000-01-01", end="2024-01-01",
                   auto_adjust=True)[["High", "Low", "Close"]]
ohlc.columns = ["high", "low", "close"]

high_low  = ohlc["high"] - ohlc["low"]
high_prev = (ohlc["high"] - ohlc["close"].shift(1)).abs()
low_prev  = (ohlc["low"]  - ohlc["close"].shift(1)).abs()
tr = pd.concat([high_low, high_prev, low_prev], axis=1).max(axis=1)
atr = tr.ewm(alpha=1/14, adjust=False).mean()

target_vol = 0.10  # aim for 10% annualized vol
position_size = target_vol / (atr / ohlc["close"] * np.sqrt(252))
position_size = position_size.clip(upper=1.0)  # never lever
returns_vol = returns * position_size

PROMPT

Use numpy, pandas, and yfinance to compute Wilder’s ATR from OHLC data and scale positions to a target annualized volatility.

This won’t make the strategy money it wasn’t going to make. It will reduce drawdowns during volatile periods.

2. Volatility filter. When realized volatility is below its long-run median, the signal is more reliable. When vol is elevated, the signal is mostly noise. Skip trades when vol is too high.

3. Longer lookbacks for trend confirmation. A 30/100 crossover whipsaws. A 50/200 (“golden cross”) whipsaws less, but signals even later. Pick based on what regime you expect to trade in. There’s no universal answer.

None of these adjustments turn the strategy into a winner. They turn it into a strategy with a more honest distribution of outcomes. That’s the actual goal.

The takeaway

The moving average crossover is not a strategy you trade. It’s a calibration exercise. You build it to learn how backtests work, where the bugs hide, what transaction costs do to fragile signals, and how regime dependence wrecks aggregated metrics.

If your first strategy looks great, suspect data error. If your first strategy looks mediocre, you’ve done it right.

The next move is to understand the ways backtests lie, systematically. That’s the topic of the next article, and the topic where most retail quants lose years.

References

  • Brock, William, Josef Lakonishok, and Blake LeBaron. “Simple Technical Trading Rules and the Stochastic Properties of Stock Returns.” Journal of Finance 47, no. 5 (1992): 1731-1764.
  • Aronson, David. Evidence-Based Technical Analysis: Applying the Scientific Method and Statistical Inference to Trading Signals. Wiley, 2006. Chapters 7-9. ISBN 978-0470008744.
  • Chan, Ernest. Quantitative Trading: How to Build Your Own Algorithmic Trading Business. Wiley, 2008. Chapter 7 (Special Topics, including MA crossover discussion). ISBN 978-0470284889.