Time in the market vs. timing the market with CAPE

Python
Backtesting
Analysis
Code
In this backtest, time in the market earns higher returns at the cost of larger drawdowns, while timing the market with CAPE can reduce them, but at the cost of lower returns.
Published

October 28, 2026

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 and its median, January 1881 to September 2026 A chart of CAPE from January 1881 to June 2026 and its median up to each month from the end of 1890, with the months the timing strategy was in stocks shaded. CAPE is the S&P 500 price divided by the average of the last ten years' earnings, adjusted for inflation. Each month, I compare CAPE with its median, the middle of all CAPE values up to that month. The median needs ten years of values, so it starts at the end of 1890. If CAPE is at or below its median, I view the market as cheap and invest in it, as marked in the shaded months. If it is above, the money is in the savings account.
  • 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%.

The value of $1 and the drawdown of time in the market and timing the market, January 1891 to September 2026, after inflation A chart with two panels from January 1891 to June 2026, after inflation. The top panel shows the value of $1 in time in the market and in timing the market, on a log scale. The bottom panel shows how far each strategy stood below its previous high. From January 1891 to August 1929, timing the market meant being in stocks 60% of the months and in the savings account the rest. By June 1932, time in the market had fallen 76.8% from its high in September 1929. Timing the market held no stocks until June 1931 and fell 58.6% from its August 1931 high. From 1933 to May 2009, timing the market was in stocks in 44% of the months. By May 2009, $1 had grown to $1,194 with time in the market and to $346 with timing the market. From June 2009, CAPE stayed above its median, so timing the market stayed in the savings account, where $1 grew from $346 to $350. In June 2026, $1 had grown to $8,587 with time in the market and to $350 with timing the market.

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()