Data are from the US stock market, since 1793: 250y of data.
The thesis is that when you take long term bonds, except for some periods (1940-1980) bonds and stocks surprisingly lead to similar results!
import numpy as np
import pandas as pd # Note: needs xlrd and openpyxl to be installed to read excel files
import ssl
import plotly.express as px
import plotly.io as pio
pd.set_option('plotting.backend', 'plotly')
pio.renderers.default = 'notebook'
ssl._create_default_https_context = ssl._create_unverified_context # Needed to download data
import pandas as pd
import requests
import io
url_dataset = "http://www.edwardfmcquarrie.com/wp-content/uploads/2021/07/Real-returns-on-stocks-and-bonds-1793-to-2019-version-2-0.xlsx"
headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36'
}
response = requests.get(url_dataset, headers=headers)
response.raise_for_status() # Raise an HTTPError for bad responses
# Read the content into a DataFrame
dfy = (
pd.read_excel(io.BytesIO(response.content), sheet_name="real returns 1793-2019", skiprows=None, nrows=None)
.dropna(subset="Nominal stock return") # remove first empty rows
.rename(columns={
'To January of:': 'Year',
'Annual inflation relative': 'Inflation',
'Nominal stock return': 'Nominal Stock Return',
'Nominal bond return': 'Nominal Bond Return',
})
[['Year', 'Inflation', 'Nominal Stock Return', 'Nominal Bond Return']]
.assign(Year=lambda x: x["Year"] - 1) # refer to the retunr of the year, not at Jenuary of the next year
.assign(Inflation= lambda x: x["Inflation"] -1 ) # get inflation as pct change
.set_index("Year")
)
dfy
Inflation | Nominal Stock Return | Nominal Bond Return | |
---|---|---|---|
Year | |||
1793 | 0.072641 | -0.076413 | -0.072395 |
1794 | 0.127533 | 0.198080 | 0.153205 |
1795 | 0.095134 | 0.098992 | -0.010299 |
1796 | 0.006405 | -0.035020 | -0.060962 |
1797 | -0.035402 | 0.133346 | 0.154601 |
... | ... | ... | ... |
2014 | -0.000893 | 0.108304 | 0.203144 |
2015 | 0.013731 | -0.047046 | -0.059783 |
2016 | 0.025000 | 0.221602 | 0.057681 |
2017 | 0.020705 | 0.240163 | 0.097465 |
2018 | 0.015512 | -0.029244 | 0.003409 |
226 rows × 3 columns
dfy.plot().update_layout(yaxis_title="Percentage Change", yaxis_tickformat=".0%" ,margin=dict(l=5, r=5, t=40, b=5), width=1000)
# for each decade calculate the average return
# add years between 1790 and 1793
dfy_decade = dfy.copy()
dfy_decade.loc[1790:1792] = np.nan
dfy_decade = dfy_decade.groupby(dfy.index // 10).mean()
dfy_decade.index = dfy_decade.index * 10
px.bar(dfy_decade, barmode='group').update_layout(
title="Average Returns by Decade",
yaxis_title="Percentage Change", yaxis_tickformat=".0%" ,margin=dict(l=5, r=5, t=40, b=5), width=1000)
dfy_cum = dfy + 1
dfy_cum = dfy_cum.cumprod()
dfy_cum.plot().update_layout(yaxis_type="log").update_layout(
title="Nominal Cumulative Returns",
yaxis_title="Value relative to first year",margin=dict(l=5, r=5, t=40, b=5), width=1000
).add_vrect(x0=1940, x1=1980, fillcolor="green", opacity=0.1, layer="below", line_width=0
)
# Real returns
dfy_real = (
dfy
.assign(Real_Stock_Return=lambda x: (1 + x["Nominal Stock Return"]) / (1 + x["Inflation"]) - 1)
.assign(Real_Bond_Return=lambda x: (1 + x["Nominal Bond Return"]) / (1 + x["Inflation"]) - 1)
[['Real_Stock_Return', 'Real_Bond_Return']]
)
dfy_real_cum = dfy_real + 1
dfy_real_cum = dfy_real_cum.cumprod()
dfy_real_cum.plot().update_layout(yaxis_type="log").update_layout(
title="Real Returns",
yaxis_title="Value relative to first year",margin=dict(l=5, r=5, t=40, b=5), width=1000
).add_vrect(x0=1940, x1=1980, fillcolor="green", opacity=0.1, layer="below", line_width=0
)
print("Historical average real stock returns:", dfy_real.Real_Stock_Return.mean())
Historical average real stock returns: 0.07380060050364495
# let's alling the 1940-1980 period
dfy_real_mod = dfy_real.copy()
dfy_real_mod.loc[1940:1980, "Real_Bond_Return"] = 0.0738
dfy_real_cum = dfy_real_mod + 1
dfy_real_cum = dfy_real_cum.cumprod()
dfy_real_cum.plot().update_layout(yaxis_type="log").update_layout(
title="Real Returns (1940-1980 Bond Returns MODIFIED from flat to 7.4%)",
yaxis_title="Value relative to first year",margin=dict(l=5, r=5, t=40, b=5), width=1000
).add_vrect(x0=1940, x1=1980, fillcolor="green", opacity=0.1, layer="below", line_width=0
)
The thesis of McQuarrie is that one can say that stocks' returns outperform (long term) bonds' returns (Jeremy Siegel's thesis) only if you look at data from the 20th century and later.
Considering 250 years of US data, the returns of the two assets are surprisingly similar
the only multi-decade exception was the 1940-1980 period, where real return on bonds stayed flat (while the real return on stocks was about the same as the historical average).
Conceptually, this should not be very surprising as equity and long-term debt can be seen as the same risk of investment, even if it is a common conception that stocks should lead to higher returns as they can be wiped out sooner than bonds.
From the Appendix of McQuarrie's paper, I can see that he used data from the following sources:
I don't have these sources at my disposal, and I want to use something more practical like EFTs and FRED data.
import os
import dotenv # pip install python-dotenv
import pandas as pd
import yfinance as yf # pip install yfinance
from fredapi import Fred # pip install fredapi
dotenv.load_dotenv()
print("yfinance version:", yf.__version__)
yfinance version: 0.2.54
fred = Fred(api_key=os.getenv("FRED_API_KEY"))
# Fetch historical data on US inflation (CPI)
cpi_data = fred.get_series('CPIAUCSL')
cpi_df = pd.DataFrame(cpi_data, columns=['CPI'])
cpi_df.index.name = 'Date'
cpi_year_df = (
cpi_df
.loc[cpi_df.index.month == 1]
.assign(
Year=lambda df: df.index.year,
Change=lambda df: df["CPI"].pct_change().shift(-1)
)
.set_index("Year")
)
cpi_year_df
CPI | Change | |
---|---|---|
Year | ||
1947 | 21.480 | 0.102421 |
1948 | 23.680 | 0.013936 |
1949 | 24.010 | -0.020825 |
1950 | 23.510 | 0.079541 |
1951 | 25.380 | 0.042159 |
... | ... | ... |
2021 | 262.639 | 0.075781 |
2022 | 282.542 | 0.063403 |
2023 | 300.456 | 0.031079 |
2024 | 309.794 | 0.029994 |
2025 | 319.086 | NaN |
79 rows × 2 columns
# compare inplotly express cpi_year_df.Change vs dfy.Inflation
df_infl = pd.merge(
left=cpi_year_df.Change,
right=dfy.Inflation,
left_index=True,
right_index=True,
how="outer"
).rename(columns={"Change": "From FRED", "Inflation": "From McQuarrie"})
df_infl.plot(
).update_traces(
selector=dict(name="From FRED"), line=dict(width=5)
).update_layout(
title="Inflation Rates",
yaxis_title="Percentage Change", yaxis_tickformat=".0%",
margin=dict(l=5, r=5, t=40, b=5), width=1000
)
yfstocks_df = (
yf.Ticker("VTI") # Vanguard Total Stock Market ETF (US stocks)
.history(period="max", interval="1mo", auto_adjust=True) # adjust=True to account for splits and dividends
[["Close"]]
.rename(columns={"Close": "Price"})
.query("index.dt.month == 1")
.assign(
Year=lambda df: df.index.year,
Change=lambda df: df["Price"].pct_change().shift(-1)
)
.set_index("Year")
)
display(yfstocks_df)
df_stocks = pd.merge(
left=yfstocks_df.Change,
right=dfy["Nominal Stock Return"],
left_index=True,
right_index=True,
how="outer"
).rename(columns={"Change": "From YF", "Nominal Stock Return": "From McQuarrie"})
px.scatter(
df_stocks.reset_index(),
x="From YF",
y="From McQuarrie",
hover_data=["Year"]
).update_layout(
title="Stock Returns: compare MCQuarrie-Nominal vs VTI (YahooFin)",
xaxis_title="Percentage Change (Yahoo Finance VTI)",
yaxis_title="Percentage Change (McQuarrie)",
margin=dict(l=5, r=5, t=40, b=5), width=600, height=500
).add_shape(
type='line',
x0=-0.4, y0=-0.4,
x1=0.4, y1=0.4,
line=dict(color='black', width=1)
)
Price | Change | |
---|---|---|
Year | ||
2002 | 34.557056 | -0.220025 |
2003 | 26.953640 | 0.379811 |
2004 | 37.190929 | 0.071648 |
2005 | 39.855572 | 0.125383 |
2006 | 44.852764 | 0.141750 |
2007 | 51.210640 | -0.029341 |
2008 | 49.708065 | -0.382489 |
2009 | 30.695290 | 0.351860 |
2010 | 41.495743 | 0.242646 |
2011 | 51.564503 | 0.039582 |
2012 | 53.605530 | 0.168366 |
2013 | 62.630878 | 0.225794 |
2014 | 76.772575 | 0.130532 |
2015 | 86.793846 | -0.027279 |
2016 | 84.426178 | 0.218880 |
2017 | 102.905396 | 0.252306 |
2018 | 128.869064 | -0.022549 |
2019 | 125.963135 | 0.203136 |
2020 | 151.550812 | 0.207456 |
2021 | 182.990982 | 0.184618 |
2022 | 216.774399 | -0.083999 |
2023 | 198.565521 | 0.192004 |
2024 | 236.690826 | 0.261561 |
2025 | 298.600006 | NaN |
# Just for curiosity: let's compare with SP500 where I have more data (back to 1985 instead of 2002 for VTI), but it is a different sampling
yfstocks500_df = (
yf.Ticker("^GSPC") # S&P 500 Index
.history(period="max", interval="1mo", auto_adjust=True)
[["Close"]]
.rename(columns={"Close": "Price"})
.query("index.dt.month == 1")
.assign(
Year=lambda df: df.index.year,
Change=lambda df: df["Price"].pct_change().shift(-1)
)
.set_index("Year")
)
df_stocks500 = pd.merge(
left=yfstocks500_df.Change,
right=dfy["Nominal Stock Return"],
left_index=True,
right_index=True,
how="outer"
).rename(columns={"Change": "From YF", "Nominal Stock Return": "From McQuarrie"})
px.scatter(
df_stocks500.reset_index(),
x="From YF",
y="From McQuarrie",
hover_data=["Year"]
).update_layout(
title="Stock Returns: compare MCQuarrie-Nominal vs S&P500 (YahooFin)",
xaxis_title="Percentage Change (Yahoo Finance VTI)",
yaxis_title="Percentage Change (McQuarrie)",
margin=dict(l=5, r=5, t=40, b=5), width=600, height=500
).add_shape(
type='line',
x0=-0.4, y0=-0.4,
x1=0.4, y1=0.4,
line=dict(color='black', width=1)
)
In previous cells I've considered both SP500 (which dates back to 1985) and Vanguard Total Bond Market Index Fund (which dates back to 2002).
It would have been nicer to use the SP500 which has more data but the VTI better matches the data used by McQuarrie:
the returns from these two sets of data are very very similar, so I can assume they are the same.
bond_etfs = [
"BLV", # Vanguard Long-Term Bond ETF (US government bonds)
"VCLT", # Vanguard Long-Term Corporate Bond ETF (US corporate bonds)
]
BLEND_RATIO = 0.5
yfbonds_df = (
yf.Ticker("BLV") # Vanguard Long-Term Bond ETF (US government bonds)
.history(period="max", interval="1mo", auto_adjust=True)
[["Close"]]
.rename(columns={"Close": "Price"})
.query("index.dt.month == 1")
.assign(
Year=lambda df: df.index.year,
Change=lambda df: df["Price"].pct_change().shift(-1)
)
.set_index("Year")
).join(
other=(
yf.Ticker("VCLT") # Vanguard Long-Term Bond ETF (US government bonds)
.history(period="max", interval="1mo", auto_adjust=True)
[["Close"]]
.rename(columns={"Close": "Price"})
.query("index.dt.month == 1")
.assign(
Year=lambda df: df.index.year,
Change=lambda df: df["Price"].pct_change().shift(-1)
)
.set_index("Year")
),
how="outer",
lsuffix="_" + bond_etfs[0],
rsuffix="_" + bond_etfs[1]
).assign(
Price=lambda df: BLEND_RATIO*df["Price_" + bond_etfs[0]] + (1-BLEND_RATIO)*df["Price_" + bond_etfs[1]],
Change=lambda df: BLEND_RATIO*df["Change_" + bond_etfs[0]] + (1-BLEND_RATIO)*df["Change_" + bond_etfs[1]]
)
yfbonds_df
Price_BLV | Change_BLV | Price_VCLT | Change_VCLT | Price | Change | |
---|---|---|---|---|---|---|
Year | ||||||
2008 | 35.901684 | 0.016191 | NaN | NaN | NaN | NaN |
2009 | 36.482979 | 0.090712 | NaN | NaN | NaN | NaN |
2010 | 39.792435 | 0.063808 | 38.102699 | 0.089186 | 38.947567 | 0.076497 |
2011 | 42.331512 | 0.257824 | 41.500919 | 0.205286 | 41.916216 | 0.231555 |
2012 | 53.245609 | 0.031417 | 50.020470 | 0.065415 | 51.633039 | 0.048416 |
2013 | 54.918442 | -0.015759 | 53.292538 | 0.009313 | 54.105490 | -0.003223 |
2014 | 54.052979 | 0.220951 | 53.788864 | 0.186349 | 53.920921 | 0.203650 |
2015 | 65.996017 | -0.078047 | 63.812355 | -0.102151 | 64.904186 | -0.090099 |
2016 | 60.845230 | 0.046441 | 57.293850 | 0.113832 | 59.069540 | 0.080137 |
2017 | 63.670952 | 0.077371 | 63.815746 | 0.103114 | 63.743349 | 0.090243 |
2018 | 68.597244 | -0.002280 | 70.396049 | -0.022199 | 69.496647 | -0.012240 |
2019 | 68.440842 | 0.230346 | 68.833321 | 0.236660 | 68.637081 | 0.233503 |
2020 | 84.205948 | 0.067297 | 85.123421 | 0.060251 | 84.664684 | 0.063774 |
2021 | 89.872719 | -0.042398 | 90.252220 | -0.039607 | 90.062469 | -0.041003 |
2022 | 86.062271 | -0.181506 | 86.677612 | -0.155396 | 86.369942 | -0.168451 |
2023 | 70.441437 | -0.012861 | 73.208260 | 0.024118 | 71.824848 | 0.005629 |
2024 | 69.535522 | -0.018190 | 74.973869 | -0.006960 | 72.254696 | -0.012575 |
2025 | 68.270660 | NaN | 74.452049 | NaN | 71.361355 | NaN |
df_bonds = pd.merge(
left=yfbonds_df.Change,
right=dfy["Nominal Bond Return"],
left_index=True,
right_index=True,
how="outer"
).rename(columns={"Change": "From YF", "Nominal Bond Return": "From McQuarrie"})
px.scatter(
df_bonds.reset_index(),
x="From YF",
y="From McQuarrie",
hover_data=["Year"]
).update_layout(
title="Bonds Returns: compare MCQuarrie-Nominal vs MixedBonds (YahooFin)",
xaxis_title="Percentage Change (Yahoo Finance Bond)",
yaxis_title="Percentage Change (McQuarrie)",
margin=dict(l=5, r=5, t=40, b=5), width=600, height=500
).add_shape(
type='line',
x0=-0.4, y0=-0.4,
x1=0.4, y1=0.4,
line=dict(color='black', width=1)
)
For bonds I obtained a very good agreement by mixing 50:50 the returns of a long term treasury ETF with invetment grade corporate bonds ETF.
TODO: I could optimize further the ratio to improve the agreement but there is not much margin for improvement.
Now, I can update McQuarrie's data with the new data I have obtained.
dfy_real
Real_Stock_Return | Real_Bond_Return | |
---|---|---|
Year | ||
1793 | -0.138960 | -0.135214 |
1794 | 0.062568 | 0.022768 |
1795 | 0.003523 | -0.096274 |
1796 | -0.041162 | -0.066938 |
1797 | 0.174941 | 0.196976 |
... | ... | ... |
2014 | 0.109295 | 0.204220 |
2015 | -0.059954 | -0.072518 |
2016 | 0.191806 | 0.031883 |
2017 | 0.215006 | 0.075203 |
2018 | -0.044072 | -0.011919 |
226 rows × 2 columns
last_year_mcquarrie = dfy_real.index.max()
print("Last year in McQuarrie data:", last_year_mcquarrie)
dfy_real_new = (
df_infl[["From FRED"]].rename(columns={"From FRED": "Inflation"}
).join(df_stocks[["From YF"]].rename(columns={"From YF": "Nominal_Stock_Returns"})
).join(df_bonds[["From YF"]].rename(columns={"From YF": "Nominal_Bond_Returns"})
)
.query("index > @last_year_mcquarrie")
.assign(
Real_Stock_Return=lambda x: (1 + x["Nominal_Stock_Returns"]) / (1 + x["Inflation"]) - 1,
Real_Bond_Return=lambda x: (1 + x["Nominal_Bond_Returns"]) / (1 + x["Inflation"]) - 1
)
)
dfy_real_new
Last year in McQuarrie data: 2018
Inflation | Nominal_Stock_Returns | Nominal_Bond_Returns | Real_Stock_Return | Real_Bond_Return | |
---|---|---|---|---|---|
Year | |||||
2019 | 0.025998 | 0.203136 | 0.233503 | 0.172650 | 0.202248 |
2020 | 0.013553 | 0.207456 | 0.063774 | 0.191310 | 0.049549 |
2021 | 0.075781 | 0.184618 | -0.041003 | 0.101170 | -0.108557 |
2022 | 0.063403 | -0.083999 | -0.168451 | -0.138614 | -0.218030 |
2023 | 0.031079 | 0.192004 | 0.005629 | 0.156074 | -0.024684 |
2024 | 0.029994 | 0.261561 | -0.012575 | 0.224824 | -0.041330 |
2025 | NaN | NaN | NaN | NaN | NaN |
# let's alling the 1940-1980 period
dfy_real_mod = dfy_real.copy()
dfy_real_mod.loc[1940:1980, "Real_Bond_Return"] = 0.0738
dfy_real_mod = pd.concat([dfy_real_mod, dfy_real_new[['Real_Stock_Return', 'Real_Bond_Return']]])
dfy_real_cum = dfy_real_mod + 1
dfy_real_cum = dfy_real_cum.cumprod()
dfy_real_cum.plot().update_layout(yaxis_type="log").update_layout(
title=f"Real Returns (1940-1980 Bond Returns MODIFIED from flat to 7.4%), w/post-{last_year_mcquarrie} data",
yaxis_title="Value relative to first year",margin=dict(l=5, r=5, t=40, b=5), width=1000
).add_vrect(
x0=1940, x1=1980, fillcolor="green", opacity=0.1, layer="below", line_width=0
).add_vrect(
x0=2017, x1=2024, fillcolor="purple", opacity=0.1, layer="below", line_width=0
)
Surprising: there is a new divergence between stocks (outperforming) and bonds (underperforming)!
Is this a new 1940-moment where bonds will underperform for decades?
DISCLAIMER: re-check all calculations and data.