Time in the market vs. timing the market with CAPE
A common saying is that time in the market beats timing the market. One common method people suggest for timing the market is using the Cyclically Adjusted Price-to-Earnings (CAPE) ratio: the Standard & Poor’s 500 (S&P 500) price-to-earnings ratio adjusted for inflation. This post compares time in the market vs. timing the market by two simple methods. There are, of course, different ways to try to time the market, but one common and easy method, which this post uses, is to look at CAPE and compare its current value with the historical median.
Data and method
CAPE is the S&P 500 price divided by the average of the last ten years’ earnings, adjusted for inflation.
Just as people look at a chart to spot peaks and bottoms, I use an expanding median and ask whether CAPE is above or below it. If it is at or below, I view the market as cheap and invest the money in the market. If it is above, I consider the market expensive, sell, and put the money in a savings account.
For the savings account, I assume an interest rate equal to the long-term interest rate, the 10-year Treasury yield from 1953 onward. Of course, I do not know the real rate a savings account would have paid, but I use it as an approximation so that a savings account gives something.
The other strategy, time in the market, is to stay invested in the market all the time, regardless of what CAPE shows. The following animation illustrates timing the market with CAPE.
\[\begin{aligned}\text{CAPE} &= \frac{\text{S\&P 500 price}}{\text{average earnings over the last 10 years}} \\[4pt] & \text{(both after inflation)}\end{aligned}\]
\[\text{next month} = \begin{cases}\text{stocks} & \text{if CAPE} \le \text{its median} \\ \text{savings account} & \text{otherwise}\end{cases}\]
- CAPE
- Its median up to each month
- Months timing the market is in stocks
To use CAPE, I require at least ten years of data, so both methods start investing in 1891, as the data begins in 1881. The data ends in June 2026. I create the CAPE signal using the previous month’s value. I invest only $1 at the start for both methods. I have attached the code in the appendix.
Results
For this backtest, you earn more by staying in the market than by timing it. Time in the market earned 6.9% a year after inflation, and $1 grew to $8,587, but your worst drawdown, how much your portfolio lost in value compared with a prior high, was −76.8%. Timing the market earned 4.4% a year, $1 grew to $350, and its worst drawdown was −58.6%.
| Value of $1 | Below its high |
|---|
Timing the market has been out of the stock market since 2009 because markets can stay expensive for a long time. When something is viewed as expensive, investors may expect much higher growth, and the stock market may eventually grow into that valuation. So if you focus only on whether the market is expensive or not, you may miss a lot of the returns. Markets may be expensive today, but is it really smart to stay out? As the saying goes, the market can stay irrational longer than you can stay solvent.
Limitations
No one invests over a horizon like the data. No one started investing when Shiller’s data starts and held until today. No one can live that long, at least to my knowledge.
Savings accounts may also pay less than the rate I use, and some are negotiated personally, so it is difficult to assign a realistic value.
Conclusion
So, in this backtest, time in the market lets you earn a higher return at the cost of larger drawdowns, while timing the market reduces returns in exchange for lower drawdowns.
Appendix: the code
# First, I define the folder I work from, the website I get the data from, and when the two
# strategies start. The median needs 120 months of CAPE values, ten years, before I use it, so that
# is when timing the market starts, and time in the market starts at the same time.
# Reading Shiller's .xls file needs the package xlrd (pip install xlrd).
import io
import json
import re
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import requests
FOLDER = Path.cwd() # the folder this notebook runs in
CACHE = FOLDER / "nr24b_shiller_data.csv"
NUMBERS = FOLDER / "nr24b_numbers.json"
CHART = FOLDER / "nr24b_growth.png"
SHILLER_PAGE = "https://shillerdata.com/"
MEDIAN_START = 120
END = "2026-06" # the last month with dividends and earnings in Shiller's file# I download the data once. It is monthly, and I save it as a comma-separated values (CSV) file that
# I load into the notebook.
if CACHE.exists():
shiller = pd.read_csv(CACHE, index_col="month")
shiller.index = pd.PeriodIndex(shiller.index, freq="M")
else:
page = requests.get(SHILLER_PAGE, timeout=60).text
link = "https:" + re.search(r'href="(//[^"]*ie_data\.xls[^"]*)"', page).group(1)
raw = pd.read_excel(io.BytesIO(requests.get(link, timeout=120).content),
sheet_name="Data", header=None, skiprows=8)
raw = raw[pd.to_numeric(raw[0], errors="coerce").notna()]
# Shiller writes a month as a decimal, October 1871 as 1871.1, so the month is the decimals times 100
months = [pd.Period(year=int(date), month=round(date % 1 * 100), freq="M") for date in raw[0].astype(float)]
shiller = pd.DataFrame({"cpi": raw[4].to_numpy(), "gs10": raw[6].to_numpy(),
"stock_index": raw[9].to_numpy(), "cape": raw[12].to_numpy()},
index=pd.PeriodIndex(months, name="month")).apply(pd.to_numeric, errors="coerce")
shiller.to_csv(CACHE)
shiller = shiller.loc[:END]
print(f"Shiller's data: {shiller.index[0]} to {shiller.index[-1]}, CAPE from {shiller['cape'].first_valid_index()}")Shiller's data: 1871-01 to 2026-06, CAPE from 1881-01
# Then I compute the monthly returns of the stock market and of the savings account, after inflation.
# The stock market: this month's value of Shiller's total return index over last month's, minus one
stocks = shiller["stock_index"].pct_change()
# The savings account: one month of interest at the previous month's long-term interest rate, less inflation
savings = (1 + shiller["gs10"].shift(1) / 1200) * shiller["cpi"].shift(1) / shiller["cpi"] - 1# I compute the expanding median of CAPE to decide when I view the market as cheap or expensive.
# I shift the signal one month so each month's position uses only values up to the previous month.
median = shiller["cape"].expanding(min_periods=MEDIAN_START).median()
cheap = (shiller["cape"] <= median).astype(float).where(median.notna() & shiller["cape"].notna())
in_stocks = cheap.shift(1)
print(f"First signal: {cheap.first_valid_index()}, median {median.dropna().iloc[0]:.1f}")First signal: 1890-12, median 15.9
# Then I code the two strategies. Time in the market always holds stocks, regardless of the
# environment. Timing the market holds stocks when they are viewed as cheap, and the savings account
# otherwise. I grow each strategy using a cumulative product.
months = in_stocks.dropna().index
returns = pd.DataFrame({
"Time in the market": stocks[months],
"Timing the market": np.where(in_stocks[months] == 1, stocks[months], savings[months]),
}, index=months)
growth = (1 + returns).cumprod()
print(f"Comparison: {months[0]} to {months[-1]}, {len(months)} months")Comparison: 1891-01 to 2026-06, 1626 months
# Then I compute the compound annual growth rate by taking the growth of $1, raising it to the power
# of one divided by the number of years, and subtracting 1. I also see how much $1 grew to, what the
# worst drawdown was for both strategies, and their volatility.
years = len(months) / 12
drawdown = growth / growth.cummax().clip(lower=1) - 1 # the starting $1 counts as the first high
summary = pd.DataFrame({
"return a year": growth.iloc[-1] ** (1 / years) - 1,
"$1 grew to": growth.iloc[-1],
"worst drawdown": drawdown.min(),
"worst drawdown at": drawdown.idxmin().astype(str),
"volatility a year": returns.std() * np.sqrt(12),
})
print(summary.to_string(formatters={"return a year": "{:.1%}".format, "$1 grew to": "${:,.0f}".format,
"worst drawdown": "{:.1%}".format, "volatility a year": "{:.1%}".format}))
held = in_stocks[months]
print(f"\nMonths in stocks: {held.mean():.0%}, switches: {int(held.diff().abs().sum())}, "
f"last month in stocks: {held[held == 1].index[-1]}")
print(f"CAPE in {months[-1]}: {shiller['cape'][months[-1]]:.1f}, median {median[months[-1]]:.1f}") return a year $1 grew to worst drawdown worst drawdown at volatility a year
Time in the market 6.9% $8,587 -76.8% 1932-06 14.6%
Timing the market 4.4% $350 -58.6% 1932-06 11.0%
Months in stocks: 43%, switches: 41, last month in stocks: 2009-05
CAPE in 2026-06: 40.2, median 16.6
# Finally, I save the numbers the post uses, and I draw the chart. On the x-axis, I have the years,
# and on the y-axis, the value of $1 on a log scale, so falls and increases are comparable over time.
# The drawdown is attached in the same graph.
numbers = {
"period": [str(months[0]), str(months[-1])],
"summary": {name: {key: (value if isinstance(value, str) else float(value)) for key, value in row.items()}
for name, row in summary.iterrows()},
"share_in_stocks": float(held.mean()),
"switches": int(held.diff().abs().sum()),
"last_in_stocks": str(held[held == 1].index[-1]),
"months": [str(month) for month in months],
"growth": {name: growth[name].round(6).tolist() for name in growth},
"cape": shiller["cape"][months].round(4).tolist(),
"median": median[months].round(4).tolist(),
"in_stocks": held.astype(int).tolist(),
}
NUMBERS.write_text(json.dumps(numbers), encoding="utf-8")
fig, (top, bottom) = plt.subplots(2, 1, figsize=(10, 7.5), sharex=True, height_ratios=[2, 1])
for name, color in [("Time in the market", "#0a2540"), ("Timing the market", "#d2822b")]:
top.plot(growth.index.to_timestamp(how="end"), growth[name], color=color, lw=2, label=name)
bottom.plot(drawdown.index.to_timestamp(how="end"), drawdown[name] * 100, color=color, lw=1.5)
top.set_yscale("log")
top.set_ylabel("Value of $1 after inflation (log scale)")
top.legend(frameon=False)
bottom.set_ylabel("Below its previous high (%)")
for axis in (top, bottom):
axis.grid(alpha=0.3)
fig.tight_layout()
fig.savefig(CHART, dpi=200)
plt.show()Read next
- The Magic Formula worked. Just not lately. Greenblatt’s Magic Formula returned 11.1% a year after trading costs, against 8.7% for the S&P 500 from 2001 to 2026, and has trailed it since about 2015.
- Which part of shareholder yield pays? From 2001 to 2026, the 30 large US stocks with the highest dividend yield beat the S&P 500 by 1.7 points a year after trading costs.
Disclaimer: simplified, hypothetical backtest without trading costs or taxes, for discussion purposes only. Not investment advice. Past performance does not guarantee future returns.
Data: Robert J. Shiller, US stock market data (shillerdata.com), monthly from 1871 to June 2026, downloaded on 28 October 2026. I publish only results computed from the data.