A century of the 200-day rule: where the edge came from
Stay in the stock market while it is above its 200-day average, move to Treasury bills when it falls below: one of the oldest trend-following rules. Does it beat simply holding the market — and when?
01A hundred years of daily returns
Load the file, turn percentages into fractions, and add the T-bill rate back to get the market’s total return.
import numpy as np
import pandas as pd
url = ('https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/ftp/'
'F-F_Research_Data_Factors_daily_CSV.zip')
ff = pd.read_csv(url, skiprows=4, index_col=0) # pandas unzips it
ff = ff[ff.index.astype(str).str.strip().str.fullmatch(r'\d{8}')] # drop the copyright line
ff.index = pd.to_datetime(ff.index.astype(str).str.strip(), format='%Y%m%d')
ff = ff.astype(float) / 100
r = ff['Mkt-RF'] + ff['RF'] # the market's daily total return
rf = ff['RF'] # the T-bill's
print(len(r), 'days,', r.index[0].date(), 'to', r.index[-1].date())
print('five worst days:', {d.date().isoformat(): round(v, 3) for d, v in r.nsmallest(5).items()})26317 days, 1926-07-01 to 2026-08-31
five worst days: {'1987-10-19': -0.174, '2020-03-16': -0.12, '1929-10-29': -0.115, '1929-10-28': -0.112, '1929-11-06': -0.096}
The worst day, 19 October 1987, lost 17.4% — 16 times the standard deviation of a day’s return. Three of the five worst came in the crash of autumn 1929, one in March 2020.
02The rule, without peeking
Build the market’s value from its returns and its 200-day average. The decision for each day is taken from the previous day’s close — the shift(1) — because that is all an investor would have known. Out of the market, the money earns the T-bill rate. Each switch costs 0.1%.
price = (1 + r).cumprod()
above = (price > price.rolling(200).mean()).shift(1) # yesterday's close vs its average
start = price.rolling(200).mean().first_valid_index() + pd.Timedelta(days=1)
r, rf, above = r[start:], rf[start:], above[start:].astype(bool)
switch = above != above.shift(1, fill_value=True)
rule = pd.Series(np.where(above, r, rf), index=r.index) - 0.001 * switch
def summary(x, name):
years = len(x) / 252
growth = (1 + x).prod() ** (1 / years) - 1
wealth = (1 + x).cumprod()
drawdown = (wealth / wealth.cummax() - 1).min()
sharpe = (x - rf.loc[x.index]).mean() / x.std() * np.sqrt(252)
print(f'{name:16} {growth:6.2%} a year | volatility {x.std() * np.sqrt(252):6.2%} | '
f'worst drawdown {drawdown:7.1%} | Sharpe {sharpe:.2f}')
return growth, drawdown
hold_all = summary(r, 'buy and hold')
rule_all = summary(rule, '200-day rule')
print('switches per year:', round(switch.sum() / (len(r) / 252), 1))buy and hold 9.78% a year | volatility 17.15% | worst drawdown -84.1% | Sharpe 0.45 200-day rule 10.46% a year | volatility 11.25% | worst drawdown -34.0% | Sharpe 0.67 switches per year: 5.4
Over the whole period the rule wins on every line: 10.5% a year against 9.8% for buy-and-hold, at two thirds of the volatility, and a worst drawdown of 34% instead of 84%. That is the headline result.
03Split the century
The same comparison, era by era.
eras = {'1927-1975': ('1927', '1975'),
'1976-2000': ('1976', '2000'),
'2001-2026': ('2001', '2026')}
by_era = {}
for era, (a, b) in eras.items():
print(era)
by_era[era] = (summary(r[a:b], ' buy and hold'), summary(rule[a:b], ' 200-day rule'))1927-1975 buy and hold 7.41% a year | volatility 17.24% | worst drawdown -84.1% | Sharpe 0.39 200-day rule 10.51% a year | volatility 11.12% | worst drawdown -34.0% | Sharpe 0.78 1976-2000 buy and hold 15.30% a year | volatility 14.38% | worst drawdown -33.1% | Sharpe 0.61 200-day rule 13.85% a year | volatility 11.42% | worst drawdown -23.9% | Sharpe 0.62 2001-2026 buy and hold 9.47% a year | volatility 19.31% | worst drawdown -54.6% | Sharpe 0.47 200-day rule 7.13% a year | volatility 11.38% | worst drawdown -22.9% | Sharpe 0.51
The whole-sample edge comes from 1927–1975, when the rule sidestepped most of the 1929–32 crash: 10.5% a year against 7.4%. Since 1976 it has earned less than simply holding the market — 13.8% against 15.3%, then 7.1% against 9.5% — while still cutting the worst drawdown, from 33% to 24% and from 55% to 23%. In the recent eras it behaved like insurance, not like an engine of return. Sullivan, Timmermann and White found the same pattern on the Dow Jones index: the best rules over 1897–1986 survived a correction for data snooping, but their performance did not repeat in the decade that followed.
04The bug that nearly doubles the result
Remove the shift(1): decide each day using that same day’s close — which is only known once the day’s return has already happened.
peek = (price > price.rolling(200).mean())[start:] cheat = pd.Series(np.where(peek, r, rf), index=r.index) cheat -= 0.001 * (peek != peek.shift(1, fill_value=True)) cheat_all = summary(cheat, 'with look-ahead')
with look-ahead 18.69% a year | volatility 11.33% | worst drawdown -25.0% | Sharpe 1.30
One missing shift, and the rule earns 18.7% a year — 1.8 times the honest 10.5%. A day’s own return is what pushes the price across its average, so the bugged rule is in the market for the rise that crosses it upward and out of it for the fall that crosses it downward. Bugs in backtests are rarely this visible; most leak a little information and inflate a little, which is why an implausibly good backtest should be checked line by line before anything else.
What this case shows
- A full-sample backtest is an average over regimes. Split it by era before believing it: here one era supplies the whole century’s edge.
- Lag every signal to the information available at the time. A one-day look-ahead added eight points a year.
- Judge a rule on what it is for. Since 1976 the 200-day rule has cost return and reduced drawdowns — a trade some investors want, but not the free lunch the full sample suggests.
- Include trading costs and count the trades: five or so switches a year is cheap; a faster rule may not be.
The patterns behind it
- Walk-Forward Validation — testing a rule on data it was not designed on
- Simple Moving Average — the moving average itself
- Drawdown Analysis — what the rule actually reduces
- The Garden of Forking Paths — many rules tried, one reported
- Tail Risk — days like 19 October 1987
Run it yourself
- Download the notebook — the same steps, for Jupyter on your own machine; it downloads the data itself
- Open it in JupyterLite — Jupyter in the browser, nothing to install
Sources
- K. R. French, Data Library: Fama/French 3 Factors [Daily], Tuck School of Business — the data (CRSP-based)
- W. Brock, J. Lakonishok & B. LeBaron, “Simple Technical Trading Rules and the Stochastic Properties of Stock Returns”, Journal of Finance 47(5), 1992
- R. Sullivan, A. Timmermann & H. White, “Data-Snooping, Technical Trading Rule Performance, and the Bootstrap”, Journal of Finance 54(5), 1999
- M. T. Faber, “A Quantitative Approach to Tactical Asset Allocation”, Journal of Wealth Management 9(4), 2007
Last reviewed: 2 October 2026 · How this site checks its content · Report a mistake