Why the 60/40 portfolio is not dead
Hakyung Kim from the Financial Times compared the stock-bond frontiers of two sample periods, 1986 to 2020 and 2021 to 2025, in the Unhedged newsletter on 29 August 2026, and asked whether bonds have stopped diversifying, in the sense that the curve for 2021 to 2025 does not bend.
The frontier is the set of risk and return points I get when I vary the weights across the two assets, stocks and bonds, plotted on a graph. It shows how the portfolio varies in risk and in return across these different weights. The curve bends when some mix has a lower volatility than either asset alone.
This post computes the frontier on a rolling five-year basis and shows in a video how it changes over time. I show that the shape of the curve depends on two things. First, the sample period, since the shape varies over time. Second, the return frequency, whether I use annual, monthly, or daily returns, which changes the conclusion similarly.
The data
I use Aswath Damodaran’s annual returns, the FT’s own source, and daily data from LSEG Workspace, which are licensed, so I cannot share them. The downloads are in the appendix.
The frontier
The code computes the frontier for the two assets, held long only and fully invested, and reproduces the FT’s 2021 to 2025 figures for the 60/40 portfolio to every printed decimal.
# Damodaran / NYU Stern, January 2026 vintage: S&P 500 including dividends, and 10-year
# Treasury total return. This is the FT's own source, so the rolling window uses it unchanged.
DAMODARAN = {
1986: ( 0.1849, 0.2428), 1987: ( 0.0581, -0.0496), 1988: ( 0.1654, 0.0822),
1989: ( 0.3148, 0.1769), 1990: (-0.0306, 0.0624), 1991: ( 0.3023, 0.1500),
1992: ( 0.0749, 0.0936), 1993: ( 0.0997, 0.1421), 1994: ( 0.0133, -0.0804),
1995: ( 0.3720, 0.2348), 1996: ( 0.2268, 0.0143), 1997: ( 0.3310, 0.0994),
1998: ( 0.2834, 0.1492), 1999: ( 0.2089, -0.0825), 2000: (-0.0903, 0.1666),
2001: (-0.1185, 0.0557), 2002: (-0.2197, 0.1512), 2003: ( 0.2836, 0.0038),
2004: ( 0.1074, 0.0449), 2005: ( 0.0483, 0.0287), 2006: ( 0.1561, 0.0196),
2007: ( 0.0548, 0.1021), 2008: (-0.3655, 0.2010), 2009: ( 0.2594, -0.1112),
2010: ( 0.1482, 0.0846), 2011: ( 0.0210, 0.1604), 2012: ( 0.1589, 0.0297),
2013: ( 0.3215, -0.0910), 2014: ( 0.1352, 0.1075), 2015: ( 0.0138, 0.0128),
2016: ( 0.1177, 0.0069), 2017: ( 0.2161, 0.0280), 2018: (-0.0423, -0.0002),
2019: ( 0.3121, 0.0964), 2020: ( 0.1802, 0.1133), 2021: ( 0.2847, -0.0442),
2022: (-0.1804, -0.1783), 2023: ( 0.2606, 0.0388), 2024: ( 0.2488, -0.0164),
2025: ( 0.1778, 0.0780),
}
annual = pd.DataFrame(DAMODARAN, index=["Stocks", "Bonds"]).T.rename_axis("Year")
def moments(returns, periods_per_year=1):
"""Mean vector and sample covariance matrix, annualized by periods_per_year."""
return (returns.mean().to_numpy() * periods_per_year,
returns.cov().to_numpy() * periods_per_year)
def frontier(mean, covariance, weights=WEIGHT_GRID):
"""Volatility and return along the long-only curve, plus a mask for the efficient branch.
The mask matters because drawing the whole curve as though all of it were efficient
overstates the choices available. The dominated branch is drawn dotted instead.
"""
variance = (weights ** 2 * covariance[0, 0]
+ (1 - weights) ** 2 * covariance[1, 1]
+ 2 * weights * (1 - weights) * covariance[0, 1])
volatility = 100 * np.sqrt(np.maximum(variance, 0))
expected_return = 100 * (weights * mean[0] + (1 - weights) * mean[1])
denominator = covariance[0, 0] + covariance[1, 1] - 2 * covariance[0, 1]
lowest_risk_weight = (float(np.clip((covariance[1, 1] - covariance[0, 1]) / denominator, 0, 1))
if denominator > 1e-15 else 0.0)
if mean[0] > mean[1]:
efficient = weights >= lowest_risk_weight - 1e-12
else:
efficient = weights <= lowest_risk_weight + 1e-12 # bonds ahead, so the curve inverts
return volatility, expected_return, efficient
# Reproduce the FT's published 2021 to 2025 60/40 figures before trusting any of this
sixty_forty = annual.loc[2021:2025].to_numpy() @ np.array([0.6, 0.4])
print(f"FT 2021-2025 60/40: mean {sixty_forty.mean() * 100:.4f}%"
f" volatility {sixty_forty.std(ddof=1) * 100:.8f}%")
print( " published : mean 8.5212% volatility 14.85857124%")
assert abs(sixty_forty.mean() * 100 - 8.5212) < 1e-4
assert abs(sixty_forty.std(ddof=1) * 100 - 14.85857124) < 1e-6FT 2021-2025 60/40: mean 8.5212% volatility 14.85857124%
published : mean 8.5212% volatility 14.85857124%
The first choice: the sample period
I move a five-year window one year at a time from 1986 to 2025, which gives 36 windows.
windows = [(end - WINDOW_YEARS + 1, end) for end in range(FIRST_WINDOW_END, 2026)]
window_moments = [moments(annual.loc[first:last]) for first, last in windows]
print(f"{len(windows)} rolling windows: {windows[0]} ... {windows[-1]}")36 rolling windows: (1986, 1990) ... (2021, 2025)
The frontier varies over time because markets go through different regimes, different economic environments, where stocks and bonds perform differently. That is why the correlation between stocks and bonds is sometimes high and sometimes lower. From 2021 to 2025, the correlation is +0.77, but 2016 to 2020 also had no bend.
The second choice: the return frequency
Then I keep 2021 to 2025 fixed and change the return frequency to annual, monthly, and daily, for the same portfolio, reset to its target weights every 1 January.
def annual_rebalanced_path(window, weights=WEIGHT_GRID):
"""Daily return path of a portfolio reset to each stock weight every 1 January."""
pieces = []
for _, one_year in window.groupby(window.index.year):
# concatenate puts a 1.0 in front, so the first day is measured from the start of
# the year rather than from the previous day
stock_wealth = np.concatenate([[1.0], (1 + one_year["r_stock"].values).cumprod()])
bond_wealth = np.concatenate([[1.0], (1 + one_year["r_bond"].values).cumprod()])
# np.outer gives one column per weight, so the weights drift after 1 January
value = np.outer(stock_wealth, weights) + np.outer(bond_wealth, 1 - weights)
pieces.append(value[1:] / value[:-1] - 1)
# vstack stacks the years back into one table, one row per day
return pd.DataFrame(np.vstack(pieces), index=window.index)
def compound(returns):
"""Turn a series of returns into the one return over the whole period."""
return (1 + returns).prod() - 1
def measure(path, rule, periods_per_year):
"""Annualized mean and standard deviation of one path, measured at the given frequency."""
# resample groups the daily rows into calendar periods, and compound turns each group
# into that period's single return. rule None means keep the daily rows as they are.
measured = path if rule is None else path.resample(rule).apply(compound)
return (100 * measured.mean().values * periods_per_year,
100 * measured.std(ddof=1).values * np.sqrt(periods_per_year),
len(measured))
def efficient_branch(volatility, expected_return):
"""Efficient part of a curve built from paths: the minimum-variance point onward."""
lowest = int(np.argmin(volatility)) # argmin gives the position of the smallest
if expected_return[-1] > expected_return[0]:
return WEIGHT_GRID >= WEIGHT_GRID[lowest]
return WEIGHT_GRID <= WEIGHT_GRID[lowest]
def pareto_efficient(volatility, expected_return, tol=1e-12):
"""A point is dominated if another allocation gives at least the return at no more risk.
efficient_branch() takes a shortcut that is valid for the closed-form curve. These curves
are built from paths instead, where neither property is guaranteed, so the assertion
below checks the shortcut against the definition of dominance directly.
"""
keep = np.ones(len(volatility), bool)
for i in range(len(volatility)):
dominated = ((expected_return >= expected_return[i] - tol)
& (volatility <= volatility[i] + tol)
& ((expected_return > expected_return[i] + tol)
| (volatility < volatility[i] - tol)))
keep[i] = not dominated.any()
return keep
path = annual_rebalanced_path(panel.loc["2021":"2025", ["r_stock", "r_bond"]])
curves = {}
mean, covariance = moments(annual.loc[2021:2025]) # the annual curve is Damodaran's
annual_volatility, annual_return, annual_efficient = frontier(mean, covariance)
curves["Annual"] = dict(x=annual_volatility, y=annual_return, eff=annual_efficient,
n=5, col=C_ANNUAL)
for label, rule, periods_per_year, colour in [("Monthly", "ME", 12, C_MONTHLY),
("Daily", None, 252, C_DAILY)]:
measured_return, measured_volatility, count = measure(path, rule, periods_per_year)
curves[label] = dict(x=measured_volatility, y=measured_return,
eff=efficient_branch(measured_volatility, measured_return),
n=count, col=colour)
ORDER = ["Annual", "Monthly", "Daily"]
MARKS = [(0.00, "100% bonds", "below", 0), # (weight, label, side, x_offset in points)
(0.60, "60/40", "below", 12), # shifted right so it clears the annual curve
(1.00, "100% stocks", "above", 0)]
for label, curve in curves.items():
mismatched = int((pareto_efficient(curve["x"], curve["y"]) != curve["eff"]).sum())
assert mismatched == 0, f"{label}: the efficient split does not match the definition"
at_60 = int(round(0.60 * (len(WEIGHT_GRID) - 1)))
print(pd.DataFrame({
"Observations": {k: f"{curves[k]['n']:,}" for k in ORDER},
"60/40 return": {k: f"{curves[k]['y'][at_60]:.2f}%" for k in ORDER},
"60/40 vol": {k: f"{curves[k]['x'][at_60]:.2f}%" for k in ORDER},
"Lowest-risk vol": {k: f"{curves[k]['x'].min():.2f}%" for k in ORDER}}).to_string()) Observations 60/40 return 60/40 vol Lowest-risk vol
Annual 5 8.52% 14.86% 9.83%
Monthly 60 8.20% 11.31% 8.37%
Daily 1,246 8.22% 10.94% 7.61%
The frontier varies when I go from monthly to daily returns. The 2021 to 2025 curve has no bend on monthly returns and bends on daily returns, and the 60/40 volatility is 14.86% on annual returns and 10.94% on daily returns, though part of that gap can come from the two data sources. The difference may come from the noise in daily returns. There is no right or wrong frequency, because it depends on why you are computing the frontier.
The video
The video shows the rolling five-year windows and how the frontier varies over time, and at the end, it shows the other issue, the frequency. So the first part is the sample period, and the second is the frequency. The years in the corner give the window.
import cv2 # pip install --no-deps opencv-python
VIDEO_WIDTH, VIDEO_HEIGHT, DPI, FPS = 1440, 960, 120, 30
X_LIMITS, Y_LIMITS = (0, 26), (-6, 31) # fixed once, so the axes never move
figure = plt.figure(figsize=(VIDEO_WIDTH / DPI, VIDEO_HEIGHT / DPI), dpi=DPI)
def draw_frame(layers, badge, legend, marks_on=0):
"""One video frame as a BGR array.
layers is a list of (volatility, return, efficient mask, color, alpha).
legend is a list of (color, label, alpha). Redrawing the whole figure each frame is
slower than blitting and it keeps the code readable, which matters more here.
"""
figure.clf()
figure.patch.set_facecolor(BG)
ax = figure.add_axes([0.088, 0.115, 0.875, 0.815])
ax.set_facecolor(BG)
ax.set_xlim(*X_LIMITS)
ax.set_ylim(*Y_LIMITS)
ax.grid(True, axis="y", color=GRID, lw=1.0)
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("#D5C6B4")
for k, (x, y, efficient, colour, alpha) in enumerate(layers):
if alpha <= 0.01:
continue
ax.plot(x, y, ls=(0, (2, 2)), lw=2.2, color=colour, alpha=0.50 * alpha, zorder=3 + k)
ax.plot(np.where(efficient, x, np.nan), np.where(efficient, y, np.nan), lw=3.2,
color=colour, alpha=alpha, solid_capstyle="round", zorder=4 + k)
if k == marks_on:
for weight, label, side, x_offset in MARKS:
j = int(round(weight * (len(WEIGHT_GRID) - 1)))
ax.plot(x[j], y[j], "D" if weight == 0.60 else "o",
ms=8.5 if weight == 0.60 else 6.5, color=colour, alpha=alpha, zorder=8)
ax.annotate(label, (x[j], y[j]), xytext=(x_offset, 9 if side == "above" else -10),
textcoords="offset points", ha="center",
va="bottom" if side == "above" else "top",
fontsize=12.5, color="#3d3d3d", alpha=alpha, zorder=9)
handles = [Line2D([0], [0], color=c, lw=3.2, alpha=a, label=t) for c, t, a in legend]
drawn = ax.legend(handles=handles, loc="upper left", frameon=False, fontsize=15,
handlelength=1.9, handletextpad=0.8, labelspacing=0.62, borderaxespad=1.0)
for text, (colour, _, alpha) in zip(drawn.get_texts(), legend):
text.set_color(colour)
text.set_alpha(alpha)
ax.xaxis.set_major_formatter(mticker.FuncFormatter(lambda v, _: f"{v:.0f}"))
ax.yaxis.set_major_formatter(mticker.FuncFormatter(lambda v, _: f"{v:.0f}"))
ax.set_xlabel("Annualised volatility (%)", fontsize=14, color="#4a4a4a", labelpad=9)
ax.set_ylabel("Annualised return (%)", fontsize=14, color="#4a4a4a", labelpad=9)
ax.tick_params(labelsize=12.5, colors="#7a7168")
figure.text(0.966, 0.952, badge, ha="right", va="center", fontsize=25, color=INK)
figure.canvas.draw()
return cv2.cvtColor(np.asarray(figure.canvas.buffer_rgba()), cv2.COLOR_RGBA2BGR)def smoothstep(u):
"""Ease between two windows: flat at both ends, so the curve does not jerk at a boundary."""
u = min(max(u, 0.0), 1.0)
return u * u * (3 - 2 * u)
# avc1 is H.264, which a browser can decode. A file written with the mp4v
# codec will not play in a browser.
writer = cv2.VideoWriter(OUT_MP4, cv2.VideoWriter_fourcc(*"avc1"),
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"
frames = 0
def write(image, times=1):
global frames
for _ in range(times):
writer.write(image)
frames += 1
def annual_layer(mean, covariance):
volatility, expected_return, efficient = frontier(mean, covariance)
return (volatility, expected_return, efficient, C_ANNUAL, 1.0)
def curve_layer(label, alpha=1.0):
curve = curves[label]
return (curve["x"], curve["y"], curve["eff"], curve["col"], alpha)
FRAMES_PER_WINDOW, FADE, HOLD_BEFORE, HOLD_AFTER = 18, 15, 15, 30
ANNUAL_LEGEND = [(C_ANNUAL, "Annual", 1.0)]
# --- act one: 36 windows, moving continuously, with no pause at any window ---
write(draw_frame([annual_layer(*window_moments[0])],
f"{windows[0][0]}–{windows[0][1]}", ANNUAL_LEGEND), 20)
for i in range(len(windows) - 1):
for step in range(FRAMES_PER_WINDOW):
u = smoothstep((step + 1) / FRAMES_PER_WINDOW)
mean = (1 - u) * window_moments[i][0] + u * window_moments[i + 1][0]
covariance = (1 - u) * window_moments[i][1] + u * window_moments[i + 1][1]
shown = windows[i] if u < 0.5 else windows[i + 1]
write(draw_frame([annual_layer(mean, covariance)],
f"{shown[0]}–{shown[1]}", ANNUAL_LEGEND))
# The roll ends on this frame and act two starts on the same one, so act two must not hold
# again. Two stacked holds made Monthly take twice as long to arrive as Daily.
write(draw_frame([annual_layer(*window_moments[-1])], "2021–2025", ANNUAL_LEGEND), HOLD_BEFORE)
# --- act two: the same portfolio, monthly then daily ------------------------
for k, label in ((1, "Monthly"), (2, "Daily")):
for step in range(FADE):
alpha = smoothstep((step + 1) / FADE)
layers = [curve_layer(x) for x in ORDER[:k]] + [curve_layer(label, alpha)]
legend = ([(curves[x]["col"], x, 1.0) for x in ORDER[:k]]
+ [(curves[label]["col"], label, alpha)])
write(draw_frame(layers, "2021–2025", legend))
layers = [curve_layer(x) for x in ORDER[:k + 1]]
legend = [(curves[x]["col"], x, 1.0) for x in ORDER[:k + 1]]
write(draw_frame(layers, "2021–2025", legend),
HOLD_BEFORE if label == "Monthly" else HOLD_AFTER)
write(draw_frame([curve_layer(x) for x in ORDER], "2021–2025",
[(curves[x]["col"], x, 1.0) for x in ORDER]), 165)
writer.release()
plt.close(figure)
print(f"Video saved: {OUT_MP4}")
print(f" {frames:,} frames {frames / FPS:.1f}s {VIDEO_WIDTH}x{VIDEO_HEIGHT} @ {FPS}fps")Video saved: C:\Users\alexa\frontier.mp4
905 frames 30.2s 1440x960 @ 30fps
Limitations
The limitation is that I only rebalance yearly, and I do not vary that. I keep it simple with annual rebalancing.
The y-axis is the arithmetic mean, but it follows both the FT and Markowitz, so I do not think that is an issue, but normally, you would be compounding.
Conclusion
So a reader should be careful about concluding too quickly whether something diversifies. Before drawing such a conclusion, be mindful of the sample period and the return frequency. Here, one curve, from one five-year window at one frequency, does not show that the 60/40 portfolio is dead.
Appendix: the downloads and the bond returns
The appendix opens the LSEG session and downloads the two daily series.
import os # path joins and the cache check
import time # sleep() paces the LSEG calls under the rate limit
import warnings
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
from matplotlib.lines import Line2D # legend handles, a swatch plus a label
warnings.filterwarnings("ignore")
DATA_DIR = os.path.expanduser("~") # anywhere you can write
SPX_CSV = os.path.join(DATA_DIR, "spxtr_daily.csv")
Y10_CSV = os.path.join(DATA_DIR, "us10y_daily.csv")
OUT_MP4 = os.path.join(DATA_DIR, "frontier.mp4")
START_DATE = "1993-01-01" # ask early, LSEG clips to what exists
END_DATE = str(pd.Timestamp.now().date())
SPX_RIC = ".SPXTR" # S&P 500 total return, because .SPX is blocked
Y10_RIC = "US10YT=RR" # US 10-year benchmark note, mid yield
BOND_MATURITY = 10.0 # constant maturity, in years
WINDOW_YEARS = 5 # the FT's window length, kept for comparison
FIRST_WINDOW_END = 1990 # so the first window is 1986 to 1990
WEIGHT_GRID = np.linspace(0, 1, 501) # stock weight, 501 points for a smooth curve
# One color per return frequency, reused by every frame so the two acts match.
C_ANNUAL, C_MONTHLY, C_DAILY = "#17868A", "#D2822B", "#7C5CBF"
BG, INK, GRID = "#FCEFE3", "#1f1f1f", "#EADCCC"import httpx # patched before lseg.data ever builds a client
def patch_httpx_proxy():
"""lseg-data 2.1.1 passes proxy={} into httpx, and httpx 0.28 and later reject a dict."""
def coerce(kwargs):
proxy = kwargs.get("proxy")
if isinstance(proxy, dict): # {} or {"http": None} both mean no proxy at all
kwargs["proxy"] = next((v for v in proxy.values() if v), 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)
patch_httpx_proxy()
import lseg.data as ld # needs Workspace running and logged in on this machine
try:
ld.open_session()
print("LSEG session: open")
except Exception as error: # report it rather than swallowing it
print(f"LSEG session: FAILED - {type(error).__name__}: {str(error)[:160]}")
print(" cached CSVs will still work. A cold cache will not")def download_daily(ric, field, column, path, tries=6):
"""Download one daily series from LSEG and save it, retrying on a rate limit.
Anything that is not a rate limit is raised again, so a real error surfaces instead
of being retried into silence.
"""
for attempt in range(tries):
try:
series = ld.get_history(universe=ric, fields=[field],
start=START_DATE, end=END_DATE, interval="daily")
break
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)
series = series.dropna()
series.index.name = "Date"
series.columns = [column]
series.to_csv(path, encoding="utf-8-sig") # utf-8-sig opens cleanly in Excel
if not os.path.exists(SPX_CSV):
download_daily(SPX_RIC, "TRDPRC_1", "spxtr", SPX_CSV)
if not os.path.exists(Y10_CSV):
download_daily(Y10_RIC, "TR.MIDYIELD", "y10", Y10_CSV)
spx = pd.read_csv(SPX_CSV, parse_dates=["Date"]).set_index("Date")["spxtr"]
y10 = pd.read_csv(Y10_CSV, parse_dates=["Date"]).set_index("Date")["y10"] / 100.0 # percent to decimal
# A splice onto a different index base would show up as an impossible daily move
assert spx.pct_change().dropna().abs().max() < 0.25, "daily move above 25%, check for a base splice"
assert y10.index.to_series().diff().max() <= pd.Timedelta("30D"), "hole in the yield cache, refetch"
print(f"SPXTR {len(spx):,} days, US10Y {len(y10):,} days")SPXTR 8,483 days, US10Y 8,185 days
I only have access to the bond yield, so I derive the price from the yield to get the return from holding the bond.
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)
panel = pd.concat([spx, y10.rename("y10")], axis=1).dropna().sort_index() # common trading days only
# clip(lower=1) stops a zero-day gap, which would make the accrued coupon zero
year_fraction = panel.index.to_series().diff().dt.days.fillna(1).clip(lower=1) / 365.0
coupon = panel["y10"].shift(1) # the coupon is struck at yesterday's par yield
panel["r_bond"] = ((par_price(panel["y10"].values, coupon.values,
BOND_MATURITY - year_fraction.values) - 100) / 100
+ coupon.values * year_fraction.values) # price change plus accrued coupon
panel["r_stock"] = panel["spxtr"].pct_change()
panel = panel.dropna()
print(f"daily panel: {len(panel):,} rows {panel.index[0].date()} to {panel.index[-1].date()}")daily panel: 8,143 rows 1994-03-16 to 2026-09-09
Read next
- Same data, same trading signal, different answer The closest relative of this post. One signal, built eight defensible ways, and what the reported return does across them.
- How did diversified portfolios do when US stocks rose or fell? A 60/40 and two other diversified portfolios against the S&P 500, across the whole range of monthly returns.
- Bootstrapping your Sharpe ratio What a small sample can and cannot say about a number estimated from it.
- The Markowitz curse Why minimum-variance weights change so much when two stocks move almost together.
Disclaimer: an empirical study for discussion, not investment advice.
Sources: Aswath Damodaran, NYU Stern, annual returns on the S&P 500 and the 10-year Treasury, January 2026 vintage. Hakyung Kim, “Chart of the Week: Is the 60-40 portfolio dead?”, Financial Times Unhedged, 29 August 2026, and the follow-up “More on the 60/40 portfolio”. Edwin Elton, Martin Gruber, Stephen Brown and William Goetzmann, Modern Portfolio Theory and Investment Analysis, 9th edition, chapter 5. Daily S&P 500 total return and the US 10-year benchmark yield from LSEG Workspace (licensed, so the raw data are not reproduced here).