Tutorial
Golden cross and death cross: definition, examples and a Python backtest
The most quoted signal in technical analysis, tested on ten years of index data. It did one job well and paid for it everywhere else.
On this page
Key takeaways
- A golden cross is the day a price's 50-day moving average rises above its 200-day moving average; a death cross is the day it falls below.
- On US500 daily bars from July 2017 to September 2026, the 50/200 crossover produced four death crosses and four golden crosses.
- Holding the index only between golden and death crosses returned +110.4%, against +216.9% for buy and hold, with 5 bps of cost per switch.
- The crossover cut the 2022 loss from 19.4% to 12.5%, but it left the 2020 crash a week after the low and came back 20% higher.
- This is an educational backtest, not advice: paper trade first. TickerLayer provides market data and does not execute trades.
A golden cross happens when a short moving average of a price, by convention the 50-day, crosses above a long one, by convention the 200-day. A death cross is the opposite: the 50-day falls below the 200-day. Traders read the first as a shift into an uptrend and the second as a shift into a downtrend, and financial news reports both whenever a major index prints one.
This tutorial shows what the signal looks like on a real chart, then tests it on ten years of the US 500 benchmark using daily bars from our indices API, with trading costs, in one pandas script. The numbers are real, and the conclusion is less flattering than the headlines.
What a golden cross and a death cross look like
US500 in 2025: a death cross in April, a golden cross in July
- US500 close
- 50-day SMA
- 200-day SMA
Index level
In 2025 the pattern played out by the book, and late. The index peaked at 6,144.15 on 19 February and fell to 4,982.77 on 8 April. The 50-day average crossed below the 200-day on 14 April, four sessions after the low, at 5,405.97. The golden cross came on 1 July at 6,198.01, 14.7% above the death-cross level. Both signals were right about the trend and late about the turn, which is exactly what a 200-day average is built to be.
SMAn(t) = (Pt + Pt−1 + … + Pt−n+1) ÷ ngolden cross on day t: SMA50(t) > SMA200(t) and SMA50(t−1) ≤ SMA200(t−1)
- Pt
- The daily close on day t.
- n
- The window length in sessions: 50 for the fast average, 200 for the slow one.
- death cross
- The same test with the inequalities reversed.
SMA vs EMA: which moving average to cross
A simple moving average (SMA) weights the last 50 closes equally. An exponential moving average (EMA) weights recent closes more, with a smoothing factor of 2 ÷ (n + 1), so it turns sooner. Sooner cuts both ways: it catches turns earlier and it gets faked out more often. Run on the same data with the same costs, the two versions came out like this.
| 50/200 SMA | 50/200 EMA | |
|---|---|---|
| Death crosses | 4 | 5 |
| Golden crosses | 4 | 5 |
| Exit in 2020 | 30 Mar 2020 | 18 Mar 2020 |
| Re-entry in 2025 | 1 Jul 2025 | 19 May 2025 |
| Total return | +110.4% | +107.7% |
| Max drawdown | -33.9% | -30.0% |
| Time in the market | 82% | 84% |
The EMA got out of the 2020 crash before the 23 March low and back into the 2025 rally six weeks earlier. It paid for that speed in 2023, when it crossed down on 28 February and back up on 6 April. Over the whole window the two ended within three percentage points of each other, and neither came close to buy and hold.
Step 1: load ten years of daily bars
The backtest needs daily closes for US500 from the indices bars endpoint. Ten years is about 2,500 sessions, well inside one page of up to 5,000 bars, but the loader follows next_offset anyway so it works for any range. History depth depends on the plan, two years on Individual and ten on Business (pricing), so on an Individual key shorten START and expect only a signal or two.
Two data details matter. Index values can carry float artifacts (7743.41015625), so the loader rounds closes to two decimals. And daily bars are stamped at UTC midnight of the session date, so converting t to a UTC date gives the trading day directly, with no time-zone arithmetic.
Step 2: signals, positions and costs
The strategy is deliberately plain: hold the index while the 50-day average is above the 200-day, hold cash otherwise. Three details decide whether a backtest like this is honest.
- No look-ahead. A signal computed from Monday's close can only be acted on at or after that close, so the position is the signal shifted by one day: yesterday's signal earns today's return.
- Costs on every switch. Each move in or out pays 5 basis points, a rough stand-in for spread and slippage on a liquid index fund. Change
COST_BPSto test your own assumption. - Warm-up. The signal is undefined until 200 closes exist, so the test starts on 13 July 2017, not in September 2016.
import os
import sys
import pandas as pd
import requests
BASE_URL = "https://api.tickerlayer.com"
API_KEY = os.environ["TICKERLAYER_API_KEY"]
SYMBOL = "US500"
START, END = "2016-09-26", "2026-09-25"
FAST, SLOW = 50, 200
AVERAGE = "sma" # or "ema"
COST_BPS = 5 # charged on every switch in or out: spread plus slippage, illustrative
def daily_closes(symbol, start, end):
"""Daily closes for an index, oldest first, following pagination to the end."""
url = f"{BASE_URL}/indices/agg/{symbol}/1/day/{start}/{end}"
params = {"sort": "asc", "limit": 5000, "offset": 0}
rows = []
while True:
resp = requests.get(url, params=params, headers={"x-api-key": API_KEY}, timeout=30)
if resp.status_code != 200:
sys.exit(f"HTTP {resp.status_code}: {resp.text[:200]}")
body = resp.json()
rows.extend(body["results"])
if body.get("next_offset") is None:
break
params["offset"] = body["next_offset"]
if not rows:
sys.exit("No bars returned: check the symbol, the dates and your plan's history depth.")
df = pd.DataFrame(rows)
df["date"] = pd.to_datetime(df["t"], unit="ms", utc=True).dt.date
return df.set_index("date")["c"].astype(float).round(2)
close = daily_closes(SYMBOL, START, END)
if AVERAGE == "ema":
fast = close.ewm(span=FAST, adjust=False, min_periods=FAST).mean()
slow = close.ewm(span=SLOW, adjust=False, min_periods=SLOW).mean()
else:
fast = close.rolling(FAST).mean()
slow = close.rolling(SLOW).mean()
# 1 while the fast average is above the slow one, 0 below it, undefined until both exist.
signal = (fast > slow).astype(float).where(slow.notna())
flips = signal.diff()
print("Golden crosses:", ", ".join(str(d) for d in flips[flips == 1].index))
print("Death crosses: ", ", ".join(str(d) for d in flips[flips == -1].index))
# Trade at the close after the signal: yesterday's signal earns today's return.
position = signal.shift(1)
ret = close.pct_change()
switches = position.diff().abs().fillna(0)
strategy = (position * ret - switches * COST_BPS / 10_000).dropna()
buy_hold = ret.loc[strategy.index]
def summary(daily):
equity = (1 + daily).cumprod()
cagr = equity.iloc[-1] ** (252 / len(daily)) - 1
max_dd = (equity / equity.cummax() - 1).min()
return f"total {equity.iloc[-1] - 1:+7.1%} CAGR {cagr:+6.1%} max drawdown {max_dd:6.1%}"
print()
print(f"Window: {strategy.index[0]} to {strategy.index[-1]}, {len(strategy)} sessions")
print("Buy and hold ", summary(buy_hold))
print("50/200 crossover ", summary(strategy))
print(f"Time in market {position.loc[strategy.index].mean():.0%}, switches {int(switches.sum())}")Golden crosses: 2019-04-01, 2020-07-09, 2023-02-02, 2025-07-01
Death crosses: 2018-12-07, 2020-03-30, 2022-03-14, 2025-04-14
Window: 2017-07-13 to 2026-09-25, 2314 sessions
Buy and hold total +216.9% CAGR +13.4% max drawdown -33.9%
50/200 crossover total +110.4% CAGR +8.4% max drawdown -33.9%
Time in market 82%, switches 8Reading the backtest results
- +216.9%Buy and hold, July 2017 to September 2026
- +110.4%50/200 crossover, after costs
- 82%Share of sessions in the market
- 8Switches in just over nine years
The crossover earned about half of what holding the index did: 8.4% a year against 13.4%. Costs were not the problem, since eight switches at 5 bps each add up to 0.4% in total. Timing was.
| Death cross | US500 | Golden cross | US500 | Index move while out |
|---|---|---|---|---|
| 7 Dec 2018 | 2,633.08 | 1 Apr 2019 | 2,867.19 | +8.9% |
| 30 Mar 2020 | 2,626.65 | 9 Jul 2020 | 3,152.05 | +20.0% |
| 14 Mar 2022 | 4,173.11 | 2 Feb 2023 | 4,179.76 | +0.2% |
| 14 Apr 2025 | 5,405.97 | 1 Jul 2025 | 6,198.01 | +14.7% |
Each exit came well into the fall, and each re-entry well into the recovery. In 2020 the index peaked at 3,386.15 on 19 February and bottomed at 2,237.40 on 23 March. The death cross arrived a week after the bottom, so the strategy took the whole drawdown, the same -33.9% as buy and hold, and then missed the first 20% of the rebound.
The case for the crossover is 2022. The death cross on 14 March kept the strategy out for most of a slow, grinding decline, and it lost 12.5% that calendar year against 19.4% for the index. Slow bear markets are where trend filters help; fast crashes and V-shaped recoveries are where they hurt. Earning 4% a year on cash while out of the market would lift the crossover to about 9% a year, still well behind.
What this backtest leaves out
- Dividends.
US500is a price index, so both lines exclude them, and buy and hold, which stays invested longer, would collect more. - Interest on cash while out of the market, set to zero here.
- Taxes on each switch in a taxable account.
- Parameter search. 50 and 200 are convention, not optimized; tuning them on the same data would overfit.
- Other markets and periods. One index over nine years is one story, not a law.
Make the backtest your own
The script takes any symbol with daily bars. Point it at a stock by changing the route to /stocks/agg/US:KO/..., or at BTCUSD on /crypto/agg, which trades every day of the week, so 200 bars cover under seven months there. Pair the trend filter with momentum from the RSI indicator, or time intraday entries against VWAP.
For bulk history across many symbols, historical stock data covers bar semantics, adjustments and export. To take a signal from a backtest to live data, the Python trading bot tutorial runs a signal engine on the crypto stream in paper mode.
Questions
Is a golden cross bullish?
It is conventionally read as bullish, because the medium-term trend has turned above the long-term one. On US500 from 2017 to 2026 it marked real uptrends, but it arrived after much of each rebound.
What is a death cross in stocks?
The day a stock's or an index's 50-day moving average falls below its 200-day moving average. It says the medium-term trend has turned down, and it usually follows a decline that has already happened.
How reliable is the golden cross?
As a timing signal, not very: it lags by design. In this backtest the crossover trailed buy and hold by about five percentage points a year, but it reduced the loss in the slow 2022 decline.
Should I use SMA or EMA for a golden cross?
The classic definition uses simple moving averages. EMAs react sooner and signal more often; on US500 from 2017 to 2026 the two versions ended within three percentage points of each other.
How do I backtest a moving average crossover in Python?
Load daily closes, compute both averages with pandas, shift the signal by one day to avoid look-ahead, multiply by daily returns and subtract a cost on each switch. The script above does exactly that in about 70 lines.