Correlations with US stocks depend on which five years we measure
Ray Dalio’s All Weather portfolio combines asset classes that respond differently to surprises in growth and inflation, so that the portfolio depends less on any one economic environment. One input to choosing such asset classes is each one’s correlation with US stocks.
A correlation is one number between -1 and +1, and it measures how closely two assets moved up and down together along a straight line. At +1 every pair of monthly returns lies exactly on an upward sloping line. At -1 every pair lies on a downward sloping line. At 0 there is no straight-line relation between the two returns, and they can still be related in a way a straight line does not capture.
Over the five years to July 2026, commodities had a correlation of +0.01 with US stocks and US Treasuries +0.53. Over the five years to July 2013, commodities had +0.75 and US Treasuries -0.31, the opposite order. So, a comparison of two asset classes on one five-year correlation can reverse in another five years.
I measure the correlation between US stocks and each of six asset classes over five years of monthly returns: US Treasuries, corporate bonds, gold, commodities, developed markets outside the US, and emerging markets. I roll that five-year window through history, with windows ending from December 1992 to July 2026, and I measure the latest five years weekly and daily as well, because the interval changes the correlation too.
The data
I need seven series, US stocks and the six asset classes, daily wherever the source publishes daily. US stocks are Kenneth French’s daily market portfolio, and I build the two bond series from yields FRED publishes, the annual interest rates implied by bond prices: the 10-year Treasury yield and Moody’s Baa corporate yield. All three are free to download, and Moody’s holds the copyright on the Baa yield, so I do not republish the series itself.
The four index series, and the two traded funds I check the bond series against, come from LSEG Workspace with the same pipeline I describe in an earlier post.
Because the LSEG data is licensed, I cannot share the raw file. This page shows every block of code that produces the video, and none of the raw data. The download blocks save a local CSV, so the later steps run offline.
| Asset class | Series | What it includes |
|---|---|---|
| US stocks | Kenneth French’s daily market portfolio | dividends, US dollars |
| US Treasuries | a 10-year par bond rebuilt from FRED’s DGS10 | coupon and price change |
| Corporate bonds | a modelled 10-year par bond, priced off Moody’s Baa yield, FRED’s DBAA | coupon and price change |
| Gold | LSEG XAU=, spot, US dollars an ounce | price only, no income |
| Commodities | S&P GSCI Total Return | spot, roll and Treasury bill collateral |
| Developed ex-US | MSCI EAFE gross, large and mid cap outside the US and Canada | dividends reinvested, US dollars |
| Emerging markets | MSCI Emerging Markets gross, monthly before 2000 | dividends reinvested, US dollars |
The window length, how fast the video moves and the six asset classes are set in one block, so there is one place to change them.
import io
import os
import struct
import time
import urllib.request
import warnings
import zipfile
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
from scipy.interpolate import PchipInterpolator # the curve the dots follow between two years
warnings.filterwarnings("ignore")
plt.rcParams["font.family"] = ["Segoe UI", "Arial", "DejaVu Sans"] # the video's typography
DATA_DIR = os.path.expanduser("~") # anywhere you can write
STOCK_CSV = os.path.join(DATA_DIR, "us_stock_daily.csv")
TREASURY_YIELD_CSV = os.path.join(DATA_DIR, "treasury_yield_daily.csv")
CORPORATE_YIELD_CSV = os.path.join(DATA_DIR, "corporate_yield_daily.csv")
LSEG_CSV = os.path.join(DATA_DIR, "lseg_levels_daily.csv")
RAW_MP4 = os.path.join(DATA_DIR, "correlation_raw.mp4") # the encoder writes here first
OUT_MP4 = os.path.join(DATA_DIR, "correlation.mp4")
START_DATE = "1960-01-01" # ask early, each source clips to what it has
END_DATE = str(pd.Timestamp.now().date())
TODAY = pd.Timestamp.now().normalize()
WINDOW_MONTHS = 60 # five years of monthly returns in every estimate
LAST_MONTH = pd.Period("2026-07", "M") # the snapshot reported here, so a later download
# cannot move the windows the prose quotes
STEP_MONTHS = 12 # the video passes through one estimate a year
STALE_DAYS = 7 # how old a month's last trade may be before it is dropped
BOND_MATURITY = 10.0 # constant maturity, in years
# One row per asset class: the name used in the code, the label on the axis, and its colour.
ASSETS = [("treasury", "US Treasuries", "#17868A"),
("corporate", "Corporate bonds", "#6B8E23"),
("gold", "Gold", "#D2822B"),
("commodities", "Commodities", "#C0392B"),
("developed", "Developed ex-US", "#2B6CB0"),
("emerging", "Emerging markets", "#7C5CBF")]
ASSET_NAMES = [name for name, _, _ in ASSETS]
ASSET_LABEL = {name: label for name, label, _ in ASSETS}
BG, INK, GRID, SPINE, AXIS_TEXT = "#FCEFE3", "#1f1f1f", "#EADCCC", "#D5C6B4", "#4a4a4a"The two public sources take three downloads, one for the French file and one for each FRED yield.
FRENCH_ZIP = ("https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/ftp/"
"F-F_Research_Data_Factors_daily_CSV.zip")
FRED_CSV = "https://fred.stlouisfed.org/graph/fredgraph.csv?id={}&cosd=1900-01-01"
def download(url, tries=4):
"""Fetch one file, retrying with a growing pause, then raising so a failure is never silent."""
for attempt in range(tries):
try:
request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (research script)"})
with urllib.request.urlopen(request, timeout=120) as response:
return response.read()
except Exception as error:
if attempt == tries - 1:
raise
wait = 15 * (attempt + 1)
print(f" download failed ({type(error).__name__}), retrying in {wait}s")
time.sleep(wait)
def us_stock_level():
"""Daily wealth index of the US market portfolio, dividends included, starting at 1.
Kenneth French publishes the market return in excess of the risk-free rate (Mkt-RF) and the
risk-free rate (RF) as separate columns, both in percent, so this function adds the two back
together to get the return on the market itself.
"""
archive = zipfile.ZipFile(io.BytesIO(download(FRENCH_ZIP)))
text = archive.read(archive.namelist()[0]).decode("latin-1").splitlines()
header = 0 # the file opens with a paragraph of notes
for row, line in enumerate(text):
if "Mkt-RF" in [cell.strip() for cell in line.split(",")]:
header = row
break
# an annual table follows the daily one, and only the daily rows start with a date
dated = [line for line in text[header + 1:] if line.strip()[:8].isdigit()]
factors = pd.read_csv(io.StringIO("\n".join(dated)), header=None,
names=["date"] + [cell.strip() for cell in text[header].split(",")[1:]])
factors.index = pd.to_datetime(factors.pop("date").astype(str), format="%Y%m%d")
market_return = (factors["Mkt-RF"] + factors["RF"]) / 100
return (1 + market_return).cumprod()
def save_fred_yield(series_id, path):
"""Save one daily yield series from FRED, in percent as it is published."""
text = download(FRED_CSV.format(series_id)).decode("utf-8").splitlines()
dated = [line for line in text if not line.endswith(".")] # FRED prints a missing day as a dot
table = pd.read_csv(io.StringIO("\n".join(dated)))
yields = pd.to_numeric(table.iloc[:, 1], errors="coerce")
yields.index = pd.to_datetime(table.iloc[:, 0])
yields.dropna().sort_index().rename("yield").rename_axis("Date").to_csv(path, encoding="utf-8-sig")
if not os.path.exists(STOCK_CSV):
us_stock_level().rename("level").rename_axis("Date").to_csv(STOCK_CSV, encoding="utf-8-sig")
if not os.path.exists(TREASURY_YIELD_CSV):
save_fred_yield("DGS10", TREASURY_YIELD_CSV) # 10-year constant-maturity Treasury yield
if not os.path.exists(CORPORATE_YIELD_CSV):
save_fred_yield("DBAA", CORPORATE_YIELD_CSV) # Moody's seasoned Baa corporate bond yield
stock_level = pd.read_csv(STOCK_CSV, parse_dates=["Date"]).set_index("Date")["level"]
treasury_yield = pd.read_csv(TREASURY_YIELD_CSV, parse_dates=["Date"]).set_index("Date")["yield"] / 100
corporate_yield = pd.read_csv(CORPORATE_YIELD_CSV, parse_dates=["Date"]).set_index("Date")["yield"] / 100
print(f"US stocks {len(stock_level):,} days {stock_level.index[0]:%d %b %Y} to {stock_level.index[-1]:%d %b %Y}")
print(f"10-year yield {len(treasury_yield):,} days {treasury_yield.index[0]:%d %b %Y} to {treasury_yield.index[-1]:%d %b %Y}")
print(f"Baa yield {len(corporate_yield):,} days {corporate_yield.index[0]:%d %b %Y} to {corporate_yield.index[-1]:%d %b %Y}")US stocks 26,296 days 01 Jul 1926 to 31 Jul 2026
10-year yield 16,161 days 02 Jan 1962 to 15 Sep 2026
Baa yield 10,214 days 02 Jan 1986 to 17 Sep 2026
The LSEG block downloads six series, one call each: the four index series that become asset classes, and the two traded funds. Opening the session needs one patch first. The installed lseg-data passes an empty dictionary where recent httpx versions require None, and without the patch open_session() raises an error.
The only things printed are row counts and start dates. The step check stops the block at any daily move of 25% or more, or 45% for emerging markets, whose early observations are monthly.
Gold needs one correction after the download. In this extract LSEG’s XAU= quotes half the gold price before 16 October 1989, so the feed doubles from one day to the next on that day and on no other. The block doubles every price before that day, and it keeps a copy of the uncorrected feed because the check in the next section reports what the correction is worth.
LSEG_SERIES = {"gold": ("XAU=", "MID_PRICE"), # gold spot, US dollars an ounce
"commodities": (".SPGSCITR", "TR.PriceClose"), # S&P GSCI, spot, roll and collateral
"developed": (".MIEA00000GUS", "TRDPRC_1"), # MSCI EAFE, dividends reinvested
"emerging": (".MIEF00000GUS", "TRDPRC_1"), # MSCI Emerging Markets, dividends reinvested
"treasury_etf": ("IEF.O", "TRDPRC_1"), # iShares 7-10 year Treasury, from 2002
"corporate_etf": ("LQD.P", "TRDPRC_1")} # iShares investment grade corporates, from 2002
# The largest daily step to accept from a feed without looking at it. A bigger step is a reason to
# inspect the series before trusting it. MSCI publishes the gross emerging index monthly
# before 2000, so one of its steps can cover a whole month.
LARGEST_STEP = {"emerging": 0.45}
GOLD_SCALE_BREAK = pd.Timestamp("1989-10-16") # the day XAU= starts quoting the whole gold price
def patch_httpx_proxy():
"""lseg-data 2.1.1 passes proxy={} into httpx, and httpx 0.28 and later reject a dict."""
import httpx
def coerce(kwargs):
proxy = kwargs.get("proxy")
if isinstance(proxy, dict): # {} or {"http": None} both mean no proxy at all
kwargs["proxy"] = next((value for value in proxy.values() if value), None)
return kwargs
for client_class in (httpx.Client, httpx.AsyncClient):
if getattr(client_class.__init__, "_patched", False):
continue # idempotent, so re-running this block is harmless
original = client_class.__init__
def make(original):
def __init__(self, *args, **kwargs):
return original(self, *args, **coerce(kwargs))
__init__._patched = True
return __init__
client_class.__init__ = make(original)
def download_lseg(ric, field, tries=6):
"""Download one daily series from LSEG, retrying on a rate limit.
Anything other than a rate limit stops the download, so a real error surfaces instead of
being retried into silence.
"""
for attempt in range(tries):
try:
history = ld.get_history(universe=ric, fields=[field],
start=START_DATE, end=END_DATE, interval="daily")
return history.iloc[:, 0].dropna().sort_index()
except Exception as error:
rate_limited = ("Too many requests" in str(error)) or ("429" in str(error))
if not rate_limited or attempt == tries - 1:
raise
wait = 30 * (2 ** attempt) # 30s, 60s, 120s, 240s, 480s
print(f" rate-limited, waiting {wait}s", flush=True)
time.sleep(wait)
if not os.path.exists(LSEG_CSV):
patch_httpx_proxy()
import lseg.data as ld # needs Workspace running and logged in on this machine
ld.open_session() # no try block: without a session this has to stop
feeds = {name: download_lseg(ric, field) for name, (ric, field) in LSEG_SERIES.items()}
ld.close_session()
pd.concat(feeds, axis=1).sort_index().rename_axis("Date").to_csv(LSEG_CSV, encoding="utf-8-sig")
lseg = pd.read_csv(LSEG_CSV, parse_dates=["Date"]).set_index("Date").sort_index()
# In this extract XAU= quotes half the gold price before 16 October 1989, so the block doubles
# everything before that day to put the whole series on one scale. It measures the step on the days
# gold itself traded, because the frame also holds rows from the other five feeds. It keeps raw_gold
# because the check block reports what the correction is worth.
raw_gold = lseg["gold"].copy()
traded_gold = lseg["gold"].dropna()
step = (traded_gold / traded_gold.shift(1)).dropna()
doubling = step[(step > 1.6) & (step < 2.4)]
assert list(doubling.index) == [GOLD_SCALE_BREAK], f"doublings found: {list(doubling.index.date)}"
lseg.loc[lseg.index < GOLD_SCALE_BREAK, "gold"] *= 2
for name in lseg.columns:
biggest_step = lseg[name].dropna().pct_change().abs().max()
limit = LARGEST_STEP.get(name, 0.25)
assert biggest_step < limit, f"a step of {biggest_step:.0%} in {name}: check for a change of scale"
series = lseg[name].dropna()
print(f" {name:<14} {LSEG_SERIES[name][0]:<16} {len(series):6,} days from {series.index[0]:%d %b %Y}")
print(f"gold: scale corrected before {doubling.index[0]:%d %b %Y}") gold XAU= 14,747 days from 22 Mar 1968
commodities .SPGSCITR 14,296 days from 02 Jan 1970
developed .MIEA00000GUS 14,797 days from 31 Dec 1969
emerging .MIEF00000GUS 6,866 days from 31 Dec 1987
treasury_etf IEF.O 6,075 days from 26 Jul 2002
corporate_etf LQD.P 5,743 days from 30 Aug 2002
gold: scale corrected before 16 Oct 1989
The two series built from a yield
The LSEG download has no bond index in it, because no bond total-return index, which would include the interest paid, is entitled on this account. I therefore build the Treasury series and the corporate series from their yields.
At each observation the code reprices a bond at the new yield with the elapsed calendar time taken off its maturity, then adds the coupon accrued over that time. The coupon rate is the previous observation’s yield, treated as the par yield for a ten-year bond, the yield at which a bond paying that coupon is priced at 100. So, the bond holds a constant ten-year maturity instead of ageing like one particular note. It is the same construction as the bond leg of the stock-bond frontier.
Neither series is an index. Both are models, and the corporate one is the looser of the two. Moody’s Baa yield is an average across seasoned bonds with twenty years or more left to run, and the model treats it as the par yield on a ten-year bond. Nothing in the model defaults, and nothing recovers.
def par_price(yield_now, coupon_rate, maturity):
"""Price per 100 of a bond with semiannual coupons, given an annual yield and coupon rate."""
yield_now = np.maximum(yield_now, 1e-8) # a zero yield divides by zero in the annuity term
discount = (1 + yield_now / 2) ** (-2 * maturity)
return 100 * ((coupon_rate / yield_now) * (1 - discount) + discount)
def total_return_from_yield(bond_yield, maturity=BOND_MATURITY):
"""Total return of a modelled bond held at a constant maturity, rebuilt from its yield.
At each observation this function reprices the bond at the new yield with the elapsed calendar
time taken off its maturity, then adds the coupon accrued over that time. It sets the coupon
rate to the previous observation's yield and treats that as the par yield for `maturity`, so
the bond keeps a ten-year maturity instead of ageing like one particular note. Where the input is not a yield on
a bond of that maturity, as with Moody's Baa average, the result is a model and not an index.
"""
# observations can be days apart over a weekend, so the step is the elapsed calendar time.
# clip(lower=1) stops a zero-day gap, which would make the accrued coupon zero
year_fraction = bond_yield.index.to_series().diff().dt.days.clip(lower=1) / 365.0
coupon = bond_yield.shift(1) # the previous observation's yield, used as a par rate
price_change = (par_price(bond_yield.to_numpy(), coupon.to_numpy(),
maturity - year_fraction.to_numpy()) - 100) / 100
accrued = coupon.to_numpy() * year_fraction.to_numpy()
return pd.Series(price_change + accrued, index=bond_yield.index).dropna()
treasury_level = (1 + total_return_from_yield(treasury_yield)).cumprod()
corporate_level = (1 + total_return_from_yield(corporate_yield)).cumprod()
levels = pd.concat([stock_level.rename("stocks"), treasury_level.rename("treasury"),
corporate_level.rename("corporate"), lseg], axis=1).sort_index()
print(f"daily levels: {len(levels):,} days {levels.index[0]:%d %b %Y} to {levels.index[-1]:%d %b %Y}")
for name, label, _ in ASSETS:
print(f" {label:<18} from {levels[name].first_valid_index():%d %b %Y}")daily levels: 26,857 days 01 Jul 1926 to 17 Sep 2026
US Treasuries from 03 Jan 1962
Corporate bonds from 03 Jan 1986
Gold from 22 Mar 1968
Commodities from 02 Jan 1970
Developed ex-US from 31 Dec 1969
Emerging markets from 31 Dec 1987
A series I build from a yield has to be checked against someone else’s series. Damodaran’s annual series at NYU Stern covers the 10-year Treasury, Baa corporate bonds and gold, so it checks all three, and it covers every year from 1988, which is where the first window starts.
# Damodaran / NYU Stern, January 2026 vintage: annual returns on the 10-year Treasury, on Baa
# corporate bonds and on gold. Public, and checked against here before trusting any of the
# series built above.
DAMODARAN_ANNUAL = {
1988: ( 0.0822, 0.1568, -0.1526), 1989: ( 0.1769, 0.1631, -0.0284),
1990: ( 0.0624, 0.0565, -0.0311), 1991: ( 0.1500, 0.1640, -0.0856),
1992: ( 0.0936, 0.1368, -0.0573), 1993: ( 0.1421, 0.1644, 0.1768),
1994: (-0.0804, -0.0123, -0.0217), 1995: ( 0.2348, 0.2009, 0.0098),
1996: ( 0.0143, 0.0528, -0.0459), 1997: ( 0.0994, 0.1130, -0.2141),
1998: ( 0.1492, 0.0810, -0.0083), 1999: (-0.0825, 0.0097, 0.0085),
2000: ( 0.1666, 0.0938, -0.0544), 2001: ( 0.0557, 0.0860, 0.0075),
2002: ( 0.1512, 0.1206, 0.2557), 2003: ( 0.0038, 0.1238, 0.1989),
2004: ( 0.0449, 0.1033, 0.0465), 2005: ( 0.0287, 0.0513, 0.1777),
2006: ( 0.0196, 0.0527, 0.2320), 2007: ( 0.1021, 0.0490, 0.3192),
2008: ( 0.2010, -0.0344, 0.0432), 2009: (-0.1112, 0.1996, 0.2504),
2010: ( 0.0846, 0.0940, 0.2924), 2011: ( 0.1604, 0.1226, 0.1202),
2012: ( 0.0297, 0.0940, 0.0568), 2013: (-0.0910, -0.0113, -0.2761),
2014: ( 0.1075, 0.1075, 0.0012), 2015: ( 0.0128, -0.0150, -0.1211),
2016: ( 0.0069, 0.1152, 0.0810), 2017: ( 0.0280, 0.0915, 0.1266),
2018: (-0.0002, -0.0318, -0.0093), 2019: ( 0.0964, 0.1525, 0.1908),
2020: ( 0.1133, 0.1060, 0.2417), 2021: (-0.0442, 0.0102, -0.0375),
2022: (-0.1783, -0.1523, 0.0055), 2023: ( 0.0388, 0.0874, 0.1326),
2024: (-0.0164, 0.0174, 0.2596), 2025: ( 0.0780, 0.0696, 0.6622),
}
published = pd.DataFrame(DAMODARAN_ANNUAL, index=["treasury", "corporate", "gold"]).T
rebuilt = levels[["treasury", "corporate", "gold"]].resample("YE").last().pct_change(fill_method=None)
rebuilt.index = rebuilt.index.year
years = published.index.intersection(rebuilt.index)
print(f"against Damodaran, annual returns December to December, {years[0]} to {years[-1]}:")
for name in ("treasury", "corporate", "gold"):
gap = (rebuilt.loc[years, name] - published.loc[years, name]).abs()
agreement = rebuilt.loc[years, name].corr(published.loc[years, name])
print(f" {ASSET_LABEL[name]:<16} mean gap {100 * gap.mean():.2f}pp, worst {100 * gap.max():.2f}pp "
f"({gap.idxmax()}), correlation {agreement:.3f}")
assert agreement > 0.95, f"{name} does not track Damodaran's series"
# 1989 is the year of the gold scale break, so it is the year the correction shows up in.
raw_1989 = raw_gold.resample("YE").last().pct_change(fill_method=None).loc["1989-12-31"]
print(f" Gold in 1989 {100 * rebuilt.loc[1989, 'gold']:+.1f}% corrected, "
f"{100 * raw_1989:+.1f}% as the feed arrives, {100 * published.loc[1989, 'gold']:+.1f}% Damodaran")
assert abs(rebuilt.loc[1989, "gold"] - published.loc[1989, "gold"]) < 0.03, "the gold correction is wrong"against Damodaran, annual returns December to December, 1988 to 2025:
US Treasuries mean gap 0.70pp, worst 3.97pp (1991), correlation 0.993
Corporate bonds mean gap 0.58pp, worst 3.50pp (1991), correlation 0.993
Gold mean gap 0.76pp, worst 1.97pp (2011), correlation 0.999
Gold in 1989 -1.5% corrected, +96.9% as the feed arrives, -2.8% Damodaran
The two rebuilt bond series and the corrected gold series each have a correlation above 0.99 with Damodaran’s across the 38 annual returns. The gold correction matters for one year of the sample. Corrected, 1989 gives -1.5% against Damodaran’s -2.8%. The feed as it arrives gives +96.9%.
The correlation
Every series is now a daily level, and the correlation needs monthly returns. For each series I take one level per calendar month, from the last day it traded that month.
I drop a month when that last trading day is more than a week before the month ends, and again when the calendar has not finished the month, so a part-month never enters as a whole month. I then put each series on an unbroken run of months, so a hole breaks the chain and a two-month return never enters as a one-month return.
def period_level(daily_level, rule="M", stale_days=STALE_DAYS):
"""One level per calendar period, taken from the last day the series traded in that period.
It drops a period when that last trading day is more than `stale_days` before the period
ends, so a series that starts or stops midway never contributes a part-month return as though
it were a whole month, and it drops a period the calendar has not finished yet.
"""
traded = daily_level.dropna()
period = traded.index.to_period(rule)
last_level = traded.groupby(period).last()
last_day = pd.Series(traded.index, index=period).groupby(level=0).last()
period_end = last_level.index.to_timestamp(how="end").normalize()
stale = (period_end - pd.DatetimeIndex(last_day.to_numpy())).days > stale_days
unfinished = period_end >= TODAY
# reindex onto every period between the first and the last, so a hole breaks the chain
# and a two-month return never enters as a one-month return
every_period = pd.period_range(last_level.index[0], last_level.index[-1], freq=rule)
return last_level.where(~(stale | unfinished)).reindex(every_period)
monthly_level = pd.DataFrame({name: period_level(levels[name]) for name in levels.columns})
monthly_return = monthly_level.pct_change(fill_method=None)
print(f"monthly returns: {monthly_return.index[0]} to {monthly_return.index[-1]}")
print(f" US stocks {int(monthly_return['stocks'].notna().sum()):,} months, "
f"the last one {monthly_return['stocks'].last_valid_index()}")monthly returns: 1926-07 to 2026-09
US stocks 1,200 months, the last one 2026-07
To calculate the correlation over one window, I standardise each asset’s 60 monthly returns: subtract their average and divide by their sample standard deviation, the typical distance of a return from the average. Then I multiply the two standardised returns month by month, add the 60 products and divide by 59. One call to rolling does this for every window of 60 months. A window with a gap anywhere in it gives no estimate, so every calculated estimate uses all 60 of its monthly returns.
def rolling_correlation(period_return, months=WINDOW_MONTHS):
"""Correlation with US stocks over the last `months` periods, one estimate per period end."""
estimates = {}
for name in ASSET_NAMES:
pair = period_return[["stocks", name]].dropna() # keep a month only if both reported
pair = pair.reindex(period_return.index) # put the gaps back where they were
# rolling() counts the months it can see, so a window holding a gap gives no estimate
estimates[name] = pair[name].rolling(months).corr(pair["stocks"])
return pd.DataFrame(estimates)
correlation = rolling_correlation(monthly_return).dropna() # start where all six have a window
correlation = correlation.loc[:LAST_MONTH] # and stop at the reported snapshot
assert correlation.abs().max().max() <= 1, "a correlation outside -1 to +1"
# every twelfth row is every twelfth calendar month only while no month in between is missing
unbroken = pd.period_range(correlation.index[0], correlation.index[-1], freq="M")
assert correlation.index.equals(unbroken), "a month is missing between the first and the last"
print(f"{len(correlation)} monthly estimates, each from {WINDOW_MONTHS} monthly returns, "
f"ending {correlation.index[0]} to {correlation.index[-1]}")404 monthly estimates, each from 60 monthly returns, ending 1992-12 to 2026-07
The first window is the five years ending December 1992, because MSCI’s emerging-market index starts at the end of 1987. The last is the five years ending July 2026, which is where Kenneth French’s file ended when I built this. That month is now fixed in the code as the cutoff, so the reported windows keep ending in July 2026. Reproducing the numbers also requires the same cached inputs, because a source can revise its history.
The Damodaran check compares annual returns. A series can agree once a year and differ month by month, and it is the monthly path this post measures. I therefore also compare both bond series with a traded fund on the five-year correlation with US stocks, the quantity this post reports. The funds are price only, so they leave out distributions, and neither holds the bonds the model prices: IEF holds seven to ten year Treasuries, and LQD holds investment grade corporates across ratings and maturities.
# A series can agree once a year and differ month by month, and it is the monthly path this post
# measures. I therefore also compare both bond series with a traded fund, on the quantity this
# post reports. The funds are price only, so they leave out distributions, and each one holds a
# different set of bonds from the model. They are a check, and an imperfect one.
TRADED_FUND = {"treasury": ("treasury_etf", "IEF"), "corporate": ("corporate_etf", "LQD")}
for name, (fund, fund_name) in TRADED_FUND.items():
both_reported = monthly_return.loc[:LAST_MONTH, ["stocks", name, fund]].dropna()
# put any missing month back as a gap, so a five-year window is always five calendar years
every_month = pd.period_range(both_reported.index[0], both_reported.index[-1], freq="M")
pair = both_reported.reindex(every_month)
rebuilt_path = pair[name].rolling(WINDOW_MONTHS).corr(pair["stocks"])
fund_path = pair[fund].rolling(WINDOW_MONTHS).corr(pair["stocks"])
gap = (rebuilt_path - fund_path).abs().dropna()
print(f"{ASSET_LABEL[name]} against {fund_name}, {len(both_reported)} months "
f"from {both_reported.index[0]}:")
print(f" the five-year correlation with US stocks differs by {gap.mean():.3f} on average and "
f"{gap.max():.3f} at worst ({gap.idxmax()})")
print(f" latest window {rebuilt_path.iloc[-1]:+.3f} rebuilt against {fund_path.iloc[-1]:+.3f} traded")US Treasuries against IEF, 288 months from 2002-08:
the five-year correlation with US stocks differs by 0.019 on average and 0.053 at worst (2014-02)
latest window +0.531 rebuilt against +0.535 traded
Corporate bonds against LQD, 272 months from 2002-09:
the five-year correlation with US stocks differs by 0.077 on average and 0.368 at worst (2014-01)
latest window +0.691 rebuilt against +0.711 traded
The two bond models do not track their funds equally closely. The Treasury series and IEF differ by 0.019 on average in the five-year correlation with US stocks, and by 0.053 in the worst window. The corporate series and LQD differ by 0.077 on average and by 0.368 in the worst window.
Neither gap measures the model alone, because each fund differs from its model in more than one way. The corporate bond series is still the weakest of the seven. It is on the page because corporate bonds and US Treasuries do not give the same correlation with US stocks, and in the latest window corporate bonds had +0.69 against +0.53 for US Treasuries.
Thirty-four windows
Five years is a short sample. I slide the window through history and measure how far each correlation moves. Those 404 estimates are more than a video can show one at a time, so the video passes through one window a year, counting back from the latest. The 34 windows it shows start with the five years to July 1993 and end with the five years to July 2026. The numbers below come from those 34 windows.
def window_label(month):
"""The months one window covers: 2026-07 with a five-year window gives Aug 2021 to Jul 2026."""
first_month = month - WINDOW_MONTHS + 1
return f"{first_month.strftime('%b %Y')} – {month.strftime('%b %Y')}"
# counting back from the latest window, so the video always ends on the most recent one
anchor_positions = sorted(range(len(correlation) - 1, -1, -STEP_MONTHS))
anchors = correlation.iloc[anchor_positions]
assert anchors.notna().all().all(), "a window with no estimate would break the interpolation"
print(f"{len(anchors)} windows a year apart, {window_label(anchors.index[0])} "
f"to {window_label(anchors.index[-1])}")
print(pd.DataFrame({
"Lowest": {ASSET_LABEL[n]: f"{anchors[n].min():+.2f}" for n in ASSET_NAMES},
"Lowest ends": {ASSET_LABEL[n]: anchors[n].idxmin().strftime("%b %Y") for n in ASSET_NAMES},
"Highest": {ASSET_LABEL[n]: f"{anchors[n].max():+.2f}" for n in ASSET_NAMES},
"Highest ends": {ASSET_LABEL[n]: anchors[n].idxmax().strftime("%b %Y") for n in ASSET_NAMES},
"Latest": {ASSET_LABEL[n]: f"{anchors[n].iloc[-1]:+.2f}" for n in ASSET_NAMES}}).to_string())
print(f"lowest of all {len(correlation)} monthly windows: "
f"Developed ex-US {correlation['developed'].min():+.2f}, "
f"Emerging markets {correlation['emerging'].min():+.2f}")34 windows a year apart, Aug 1988 – Jul 1993 to Aug 2021 – Jul 2026
Lowest Lowest ends Highest Highest ends Latest
US Treasuries -0.56 Jul 2014 +0.53 Jul 2026 +0.53
Corporate bonds -0.30 Jul 2015 +0.70 Jul 2024 +0.69
Gold -0.35 Jul 1993 +0.22 Jul 2024 +0.17
Commodities -0.40 Jul 1993 +0.75 Jul 2013 +0.01
Developed ex-US +0.32 Jul 1997 +0.92 Jul 2012 +0.78
Emerging markets +0.39 Jul 1997 +0.86 Jul 2012 +0.64
lowest of all 404 monthly windows: Developed ex-US +0.27, Emerging markets +0.36
Developed markets outside the US and emerging markets had a positive correlation with US stocks in every window. Across the 34 windows the correlation was between +0.32 and +0.92 for developed markets outside the US, and between +0.39 and +0.86 for emerging markets. Taking all 404 monthly windows instead, the lowest correlation is +0.27 for developed markets outside the US and +0.36 for emerging markets.
Diversification does not require a negative correlation. Whether adding an asset class lowers a portfolio’s volatility, how much its value swings, depends on that asset class’s own volatility and on its weight as well as on the correlation.
Other correlations changed enough to reverse the comparison between two asset classes. I compare the latest window with the five years to July 2013, the window with the highest commodity correlation in the table above.
EARLIER = pd.Period("2013-07", "M")
LATEST = correlation.index[-1]
print(pd.DataFrame({
window_label(EARLIER): {ASSET_LABEL[n]: f"{anchors.loc[EARLIER, n]:+.2f}" for n in ASSET_NAMES},
window_label(LATEST): {ASSET_LABEL[n]: f"{anchors.loc[LATEST, n]:+.2f}" for n in ASSET_NAMES}}).to_string()) Aug 2008 – Jul 2013 Aug 2021 – Jul 2026
US Treasuries -0.31 +0.53
Corporate bonds +0.26 +0.69
Gold +0.17 +0.17
Commodities +0.75 +0.01
Developed ex-US +0.91 +0.78
Emerging markets +0.86 +0.64
US Treasuries had the lower correlation in the earlier window, and commodities in the latest. So, a comparison based only on the latest window would miss that the order reversed.
The same five years, measured more often
The correlations so far use monthly returns. To see whether the interval changes the answer, I hold the five years fixed and change only how often I measure the return: for August 2021 to July 2026, monthly, weekly and daily.
A correlation is never annualised: each one is computed from returns measured at its own interval, with nothing multiplied by 12, 252 or their square roots.
def correlation_in_window(period_return, month_of_row, first_month, last_month):
"""Correlation with US stocks for every asset class, over one window of calendar months.
month_of_row gives the calendar month each row belongs to, so a weekly or a daily panel can
be cut by the same window as the monthly one. Each asset keeps every period it and US stocks
both traded, so a market with its own holidays does not shorten the others.
"""
inside = (month_of_row >= first_month) & (month_of_row <= last_month)
window = period_return[inside]
estimates = {}
for name in ASSET_NAMES:
pair = window[["stocks", name]].dropna()
estimates[ASSET_LABEL[name]] = pair["stocks"].corr(pair[name])
return pd.Series(estimates)
weekly_level = pd.DataFrame({name: period_level(levels[name], rule="W-FRI", stale_days=4)
for name in levels.columns})
weekly_return = weekly_level.pct_change(fill_method=None)
daily_return = pd.DataFrame({name: levels[name].dropna().pct_change() for name in levels.columns})
FIRST_MONTH = LATEST - WINDOW_MONTHS + 1
weekly_month = weekly_return.index.to_timestamp(how="end").to_period("M")
measured = pd.DataFrame({
"Monthly": correlation_in_window(monthly_return, monthly_return.index, FIRST_MONTH, LATEST),
"Weekly": correlation_in_window(weekly_return, weekly_month, FIRST_MONTH, LATEST),
"Daily": correlation_in_window(daily_return, daily_return.index.to_period("M"), FIRST_MONTH, LATEST)})
print(f"{window_label(LATEST)}, the same five years measured three ways:")
print(measured.map(lambda value: f"{value:+.2f}").to_string())Aug 2021 – Jul 2026, the same five years measured three ways:
Monthly Weekly Daily
US Treasuries +0.53 +0.12 +0.07
Corporate bonds +0.69 +0.30 +0.16
Gold +0.17 +0.16 +0.14
Commodities +0.01 +0.11 +0.10
Developed ex-US +0.78 +0.71 +0.39
Emerging markets +0.64 +0.53 +0.24
The correlation decreases as the interval shortens for five of the six asset classes. For US Treasuries the correlation with US stocks was +0.53 on monthly returns, +0.12 on weekly returns and +0.07 on daily returns. For emerging markets the correlation was +0.64, +0.53 and +0.24. For gold it was +0.17, +0.16 and +0.14.
So, the interval changes the correlation far more for some asset classes than for others. The exception is commodities, whose correlation is +0.10 on daily returns against +0.01 on monthly ones.
A daily correlation compares the two assets’ returns on matching dates. A monthly correlation compares their compounded returns over each month. Related price changes can occur on different days, so these comparisons can give different answers.
For example, suppose US stocks rise after a foreign market has closed. The foreign market rises the next day in response to the same news. The daily comparison pairs those two rises with returns from different days. If both rises occur within the same month, the monthly comparison includes them together. This can produce a higher monthly correlation.
Closing times are one possible reason for a difference. They do not explain every difference in the table, and monthly correlation need not be higher. The estimates also vary through sampling error. To examine the timing explanation, I compare each asset’s same-day correlation with one that pairs its next session’s return with the US return.
# Many of the markets in the two foreign indices close hours before New York, so a day there and
# the same day in New York do not cover the same hours. Pairing each index with its own next
# session is one way to look at that: a higher correlation is consistent with a closing-time gap.
# The US return date sets the window, so at its edge a July return can pair with an August one.
print("daily returns over the same window, as traded and one session later:")
for name in ASSET_NAMES:
own = levels[name].dropna().pct_change()
trio = pd.concat({"stocks": daily_return["stocks"], "same": own, "next": own.shift(-1)},
axis=1).dropna()
in_window = (trio.index.to_period("M") >= FIRST_MONTH) & (trio.index.to_period("M") <= LATEST)
trio = trio[in_window]
as_traded = trio["stocks"].corr(trio["same"])
one_later = trio["stocks"].corr(trio["next"])
print(f" {ASSET_LABEL[name]:<18} {as_traded:+.2f} as traded, {one_later:+.2f} one session later "
f"({one_later - as_traded:+.2f}), {len(trio):,} days")daily returns over the same window, as traded and one session later:
US Treasuries +0.07 as traded, +0.05 one session later (-0.02), 1,246 days
Corporate bonds +0.16 as traded, +0.14 one session later (-0.02), 1,247 days
Gold +0.14 as traded, +0.05 one session later (-0.08), 1,255 days
Commodities +0.10 as traded, +0.06 one session later (-0.05), 1,255 days
Developed ex-US +0.39 as traded, +0.44 one session later (+0.05), 1,255 days
Emerging markets +0.24 as traded, +0.44 one session later (+0.20), 1,255 days
Pairing emerging markets with its own next session gives +0.44 against +0.24 as traded, and developed markets outside the US +0.44 against +0.39. For the other four asset classes the correlation is lower one session later. Only the two foreign indices increase, which is consistent with a closing-time gap. The comparison does not isolate that gap or measure its share of the difference between the monthly and the daily figure, because the two columns pair different days.
The appropriate correlation depends on the risk being measured. To assess the variability of daily portfolio returns, use correlations and volatilities of daily returns. For monthly portfolio returns, use their monthly counterparts. We can hold a portfolio for years and still care about its daily losses. Having more daily observations does not make daily correlation a better estimate of monthly correlation.
The video
Moving dots are easier to follow than a column of numbers. The video below is the output of the four blocks that follow. The first builds the frames, the second draws one, the third writes the file, and the fourth moves the index to the front of it. I fix the axes throughout, so nothing rescales while the dots move.
FRAMES_PER_STEP = 36 # frames between two estimates, 0.6s at 60 fps
FPS = 60
HOLD_END = 3.0 # seconds the last window stays on screen
# One shape-preserving curve through all of the yearly estimates, read off once per frame.
# PchipInterpolator passes exactly through every estimate and does not overshoot between
# neighbouring ones. It interpolates the values in between for the animation, and those are not
# correlation estimates. Easing each pair of years separately would stop every dot at every one.
curve = PchipInterpolator(np.arange(len(anchors)), anchors.to_numpy(), axis=0)
position = np.arange((len(anchors) - 1) * FRAMES_PER_STEP + 1) / FRAMES_PER_STEP # counted in estimates
path = curve(position)
on_estimate = np.flatnonzero(np.isclose(position, np.round(position))) # frames on an estimate
assert np.allclose(path[on_estimate], anchors.to_numpy()), "the curve misses an estimate"
for column, name in enumerate(ASSET_NAMES):
lowest, highest = anchors[name].min(), anchors[name].max()
assert path[:, column].min() >= lowest - 1e-9, f"{name}: the curve goes below every estimate"
assert path[:, column].max() <= highest + 1e-9, f"{name}: the curve goes above every estimate"
named_window = np.round(position).astype(int) # the window whose months the corner shows
frames = [(path[row], named_window[row]) for row in range(len(position))]
frames = frames + [frames[-1]] * round(HOLD_END * FPS)
# While the corner holds one window, the number beside a dot is already travelling towards the
# next year's estimate. Only the frame on an estimate is the estimate for that window.
drift = np.abs(path - anchors.to_numpy()[named_window])
print(f"{len(frames)} frames, {len(frames) / FPS:.1f} seconds, "
f"{window_label(anchors.index[0])} to {window_label(anchors.index[-1])}")
print("between one estimate and the next the number beside a dot differs")
print(f" from the window named by {drift.mean():.3f} on average and {drift.max():.3f} at most")1369 frames, 22.8 seconds, Aug 1988 – Jul 1993 to Aug 2021 – Jul 2026
between one estimate and the next the number beside a dot differs
from the window named by 0.017 on average and 0.249 at most
Each asset class keeps its own column whatever its correlation does, so every movement on screen is vertical. The columns are not joined by a line, because there is no path from one asset class to the next.
import cv2 # pip install --no-deps opencv-python
VIDEO_WIDTH, VIDEO_HEIGHT, DPI = 1440, 960, 120
RECT = [0.088, 0.105, 0.875, 0.775] # the plot, with room above it for the dates
COLUMN = np.arange(len(ASSETS)) # each asset keeps its own column, whatever it does
figure = plt.figure(figsize=(VIDEO_WIDTH / DPI, VIDEO_HEIGHT / DPI), dpi=DPI)
def draw_frame(values, label):
"""One video frame as a BGR array: one dot per asset class, the window's months in the corner.
Redrawing the whole figure each frame is slower than updating the dots in place, and it keeps
the code readable, which matters more here.
"""
figure.clf()
figure.patch.set_facecolor(BG)
ax = figure.add_axes(RECT)
ax.set_facecolor(BG)
ax.set_xlim(-0.65, len(ASSETS) - 0.35)
ax.set_ylim(-1, 1) # a correlation cannot leave this range
ax.set_yticks([-1, -0.5, 0, 0.5, 1])
ax.yaxis.set_major_formatter(mticker.FuncFormatter(lambda v, _: "0" if v == 0 else f"{v:+.1f}"))
ax.set_xticks(COLUMN)
ax.set_xticklabels([label for _, label, _ in ASSETS])
ax.grid(True, axis="y", color=GRID, lw=1.1)
ax.set_axisbelow(True)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
for side in ("left", "bottom"):
ax.spines[side].set_color(SPINE)
ax.tick_params(axis="both", length=0, labelsize=13, colors=INK, pad=10)
ax.axhline(0, color=INK, lw=1.8, zorder=3) # the line the dots move around
ax.set_ylabel("Correlation with US stocks", fontsize=17, color=AXIS_TEXT, labelpad=14)
for column, (_, _, colour), value in zip(COLUMN, ASSETS, values):
ax.plot(column, value, "o", ms=16, color=colour, zorder=4, clip_on=False)
ax.annotate(f"{value:+.2f}", (column, value), xytext=(19, 0), textcoords="offset points",
ha="left", va="center", fontsize=18, fontweight="semibold", color=colour,
annotation_clip=False, zorder=5,
# the patch keeps the number legible when a dot is on the zero line
bbox=dict(facecolor=BG, edgecolor="none", pad=1.5))
figure.text(0.966, 0.945, label, ha="right", va="center", fontsize=25, color=INK)
figure.canvas.draw()
return cv2.cvtColor(np.asarray(figure.canvas.buffer_rgba()), cv2.COLOR_RGBA2BGR)# Media Foundation is Windows only, and its H.264 output is what a browser can decode. On another
# platform pass cv2.CAP_FFMPEG here instead, which needs an H.264 encoder available to FFmpeg. On
# this machine that would be OpenH264, and it is not installed.
writer = cv2.VideoWriter(RAW_MP4, cv2.CAP_MSMF, cv2.VideoWriter_fourcc(*"H264"),
FPS, (VIDEO_WIDTH, VIDEO_HEIGHT))
# a writer that failed to open discards every frame in silence, so check it rather than trust it
assert writer.isOpened(), "OpenCV could not open the video writer"
started = time.time()
for number, (values, window) in enumerate(frames):
writer.write(draw_frame(values, window_label(anchors.index[window])))
if (number + 1) % 300 == 0:
print(f" {number + 1} of {len(frames)} frames ({time.time() - started:.0f}s)", flush=True)
writer.release()
plt.close(figure)
print(f"rendered {len(frames)} frames in {(time.time() - started) / 60:.1f} min") 300 of 1369 frames (29s)
600 of 1369 frames (58s)
900 of 1369 frames (80s)
1200 of 1369 frames (105s)
rendered 1369 frames in 2.0 min
An MP4 written this way holds the frames first and their index last, and an index at the end can delay the start of playback. The last block writes the file again with the index in front. It goes to DATA_DIR, and publishing it means copying correlation.mp4 next to the page.
def mp4_boxes(buffer, start=0, end=None):
"""Every box in one stretch of an MP4 file: its type, where it starts, its size, its header."""
end = len(buffer) if end is None else end
found, position = [], start
while position + 8 <= end:
size, kind = struct.unpack(">I4s", buffer[position:position + 8])
header = 8
if size == 1: # a box above 4 GB states its size in 64 bits
size, header = struct.unpack(">Q", buffer[position + 8:position + 16])[0], 16
elif size == 0: # a size of zero means the box runs to the end
size = end - position
found.append((kind.decode("latin-1"), position, size, header))
if size < header:
break
position += size
return found
def move_index_to_front(source, target):
"""Write the file again with its index ahead of the frames, so playback can start early.
An MP4 keeps the frames in one box (mdat) and the index of where each chunk of them starts in
another (moov). OpenCV writes mdat first, and an index at the end can delay the start of
playback. Moving moov in front pushes every chunk that much further into the file, so each
offset it holds gains moov's own size.
"""
buffer = bytearray(open(source, "rb").read())
top_level = mp4_boxes(buffer)
order = [kind for kind, _, _, _ in top_level]
assert "moov" in order and "mdat" in order, order
_, moov_at, moov_size, moov_header = top_level[order.index("moov")]
index = bytearray(buffer[moov_at:moov_at + moov_size])
def add_to_offsets(box, start, end):
for kind, position, size, header in mp4_boxes(box, start, end):
if kind in ("trak", "mdia", "minf", "stbl", "edts"):
add_to_offsets(box, position + header, position + size) # these hold other boxes
elif kind in ("stco", "co64"): # the chunk offsets, 32 bit and 64 bit versions
count = struct.unpack(">I", box[position + header + 4:position + header + 8])[0]
pattern, width = (">I", 4) if kind == "stco" else (">Q", 8)
at = position + header + 8
for _ in range(count):
moved = struct.unpack(pattern, box[at:at + width])[0] + moov_size
box[at:at + width] = struct.pack(pattern, moved)
at += width
add_to_offsets(index, moov_header, moov_size)
mdat_at = top_level[order.index("mdat")][1]
rebuilt = buffer[:mdat_at] + index + buffer[mdat_at:moov_at] + buffer[moov_at + moov_size:]
open(target, "wb").write(rebuilt)
return [kind for kind, _, _, _ in mp4_boxes(rebuilt)]
print(f"boxes in order: {move_index_to_front(RAW_MP4, OUT_MP4)}")
os.remove(RAW_MP4)
print(f"{os.path.basename(OUT_MP4)}: {VIDEO_WIDTH}x{VIDEO_HEIGHT} at {FPS} fps, {len(frames)} frames, "
f"{len(frames) / FPS:.1f}s, {os.path.getsize(OUT_MP4) / 1e6:.1f} MB")boxes in order: ['ftyp', 'uuid', 'moov', 'mdat']
correlation.mp4: 1440x960 at 60 fps, 1369 frames, 22.8s, 5.6 MB
The video passes through the 34 calculated yearly estimates, and on those frames each dot is that asset class’s correlation with US stocks over the 60 months named in the corner. Between two estimates the code interpolates the dots and the numbers beside them, and the corner names the nearest yearly window. On those interpolated frames the number beside a dot differs from the named window’s estimate by 0.017 on average and 0.249 at most, so only the frames on an estimate show a calculated correlation.
Limitations
A correlation is one input to how much an asset class reduces portfolio volatility. The volatility of a portfolio of two assets depends on the volatility of each asset, on the weight of each asset, and on the correlation between the two. This post measures the correlation, so it shows how one of those inputs changed.
The windows overlap. Two windows a year apart share four of their five years, and two monthly estimates a month apart share 59 of their 60 months. The 34 windows are therefore not 34 independent experiments, and I do not test any of the changes against sampling uncertainty. A dot that moves describes what those five years looked like. Evidence that the underlying relation changed would need an uncertainty analysis this post does not do.
The corporate bond series is modelled. It prices a ten-year par bond off an average yield on longer bonds, with no defaults. Its gap to LQD, 0.368 in the worst window, mixes the model’s error with the difference between the bonds each holds, and before August 2002 there is no traded fund on this account to check the series against.
Nothing here forecasts anything. A correlation estimated over the past five years does not establish that it will hold over the next five.
Conclusion
Which asset class had the lower correlation with US stocks changed across these 34 windows, and how often the return is measured changes the correlation as well. Developed markets outside the US and emerging markets had a positive correlation in every window.
A single five-year correlation, measured at a single interval, is weak evidence that an asset class has stopped diversifying. The takeaway is that before comparing two asset classes on their correlation with US stocks, ask which five years produced it and how often the return was measured.
Read next
- Why the 60/40 portfolio is not dead. The same two questions on one pair of assets: what a five-year window does to a stock-bond curve, and what the return interval does to it.
- How did diversified portfolios do when US stocks rose or fell? All Weather and two other diversified portfolios against the S&P 500, across the whole range of monthly returns.
- Butter prices can appear related to the S&P 500 What a high correlation between two series does and does not establish.
Disclaimer: an empirical study for discussion purposes, not investment advice. The design is descriptive and not causal. Past performance does not guarantee future returns.
Sources: Kenneth R. French, daily research factors, Tuck School of Business at Dartmouth College. Federal Reserve Bank of St Louis (FRED), series DGS10 for the 10-year Treasury yield and DBAA for Moody’s Baa corporate yield. Aswath Damodaran, NYU Stern, annual returns on the 10-year Treasury, Baa corporate bonds and gold, January 2026 vintage. Daily gold, S&P GSCI Total Return, MSCI EAFE gross and MSCI Emerging Markets gross index levels, and the IEF and LQD funds, from LSEG Workspace (licensed); I do not reproduce the raw data here.