Why the 60/40 portfolio is not dead.

Python
Statistics
Backtesting
Code
The Financial Times compared stock-bond frontiers for 1986 to 2020 and 2021 to 2025 on annual returns. Rolling a five-year window through history changes the curve repeatedly, and measuring the same five years daily instead of annually moves 60/40 volatility from 14.86% to 10.94%.
Published

September 10, 2026

Hakyung Kim ran a chart in the Financial Times Unhedged newsletter on 29 August 2026 comparing two stock-bond frontiers, 1986 to 2020 against 2021 to 2025, and asked whether bonds have stopped diversifying. The second curve has no bend. The least risky long-only mix is 100% bonds, and from there the curve is close to a straight line up to 100% stocks.

A frontier is a curve with one point for every stock-bond mix. Each point plots that mix’s average return against its volatility, so the curve runs from 100% bonds at one end to 100% stocks at the other. The bend comes from diversification. When two assets do not move together, some mix of them has lower volatility than either asset held alone, and the curve turns back on itself at that mix. A curve with no bend means that no long-only mix had lower volatility than 100% bonds.

The shape of that curve depends on two choices made when estimating it, and a chart of two fixed curves shows neither. The first is the sample period, which sets how many observations the estimate uses and which years they are. The second is the return interval, which is how often the return is measured inside that period. Neither choice is wrong. Both are made whenever a frontier is drawn, and the FT chart states only the first.

This post makes both visible. I start from the same source the FT used, Aswath Damodaran’s annual series at NYU Stern, and reproduce its published 60/40 figures exactly before changing anything, so every curve that follows is built on the FT’s own numbers. Then I roll a five-year window through history to see how much the shape moves. Then I hold 2021 to 2025 fixed and change only the measurement interval, which moves the volatility of a 60/40 portfolio from 14.86% to 10.94%. The last section renders a video where both are visible at once, and every block of code that produces it is on this page, so the same data gives the same file.

The data

I need two series: the S&P 500 total return and a 10-year Treasury total return, both daily. I pull them from LSEG Workspace with the same pipeline I describe in an earlier post. Because the data is licensed, I cannot share the raw file. This page shows the code that does the analysis and none of the raw data. Every block below caches to CSV, so the later steps run offline.

The two sources do different jobs. The annual series is Damodaran’s, which is the FT’s own and is public, so it is embedded in the code below, and both the reproduction and the rolling window use it. The LSEG panel is needed only for the monthly and daily curves, because an annual series cannot be measured more often than annually.

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 colour per return interval, reused by every frame so the two acts match.
C_ANNUAL, C_MONTHLY, C_DAILY = "#17868A", "#D2822B", "#7C5CBF"
BG, INK, GRID = "#FCEFE3", "#1f1f1f", "#EADCCC"

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. The usual response is a bare except, which hides the failure and lets every step after it run on whatever happens to be cached.

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

Each series is one call. The only things printed are row counts and dates. The two checks catch a series joined onto a different starting level, which would show up as an impossible daily move.

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

The bond leg

No bond total-return index is entitled on this account, so the 10-year return is built from the yield. Each day the code reprices the bond at the new yield with one day less to run, then adds the coupon accrued over that day. The coupon is set from the previous day’s par yield, so the bond holds a constant ten-year maturity instead of ageing like one particular note.

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

The frontier

The frontier comes from two assets, held long only and fully invested. For stock weight w the portfolio mean is w times the stock mean plus 1 - w times the bond mean, and the variance is the usual two-asset expression. Both are Equations (5.1) and (5.4) in Elton, Gruber, Brown and Goetzmann’s Modern Portfolio Theory and Investment Analysis, chapter 5, and the minimum-variance weight is their Equation (5.9), held between 0 and 1 because short sales are not allowed here.

When the correlation is high enough, the minimum-variance weight is zero, and the least risky long-only portfolio is the least risky asset held by itself. The curve then stops bending backwards. A curve without a bend means that no long-only mix has lower volatility than 100% bonds. It does not mean that bonds stopped reducing risk.

# 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, annualised 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-6
FT 2021-2025 60/40:  mean 8.5212%   volatility 14.85857124%
  published        :  mean 8.5212%   volatility 14.85857124%

The volatility matches to eight decimal places. Over 2021 to 2025 the stock-bond correlation in that series is +0.77 and the bond leg averages −2.44% a year, which is why the second curve has no bend.

The first choice: the sample period

A five-year window is short. I slide it through the sample and measure how far the curve moves. Thirty-six windows fit between 1986 and 2025. The first ends in 1990 and the last is the FT’s own 2021 to 2025.

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)

Over 2004 to 2008 the annual series gives stocks an average return of 0.02% a year and bonds 7.93%. The frontier for that window is inverted: the bond end is both less risky and higher returning, so the efficient part of the curve starts at 100% bonds and runs down towards stocks. Over 2016 to 2020 there is no bend either, for the same reason as 2021 to 2025. Windows without a bend are not new, and 2021 to 2025 is one window of thirty-six.

The second choice: the return interval

I hold the sample period fixed and change only how often the return is measured. One portfolio, reset to its target weights every 1 January and left to drift, is measured annually, monthly and daily.

The rebalancing rule has to be held fixed. Re-weighting separately aggregated monthly returns would describe a monthly-rebalanced strategy, which would confound the measurement interval with the trading rule. The code therefore builds one daily path per weight and measures that same path three ways.

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):
    """Annualised mean and standard deviation of one path, measured at the given interval."""
    # 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 safe 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, nudge in points)
         (0.60, "60/40", "below", 12),              # nudged 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 return is stable across the three. The volatility is not. The 60/40 figure is 14.86% on five annual observations and 10.94% on 1,246 daily ones, a difference of 3.92 percentage points from the measurement choice alone. The lowest-risk portfolio has a volatility of 9.83% measured annually and 7.61% measured daily, and at daily frequency the curve bends again.

Daily data do not always give the better answer. Turning a daily volatility into an annual one by multiplying by the square root of 252 assumes returns are serially independent, and they are not. The interval is a choice, and it moves the answer by more than the difference the FT chart was drawn to show.

The video

The two choices are easier to follow when the curve moves. The video below is the output of the two blocks that follow. The first draws one frame, and the second walks the window through all 36 periods and then fades in the monthly and daily curves. The axes are fixed throughout, so nothing rescales while the curve moves.

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, colour, alpha).
    legend is a list of (colour, 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, nudge 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=(nudge, 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)

The code interpolates the mean and the covariance between adjacent windows, so the curve moves continuously rather than jumping. There are still only 36 estimates behind it, and the smoothing between them is cosmetic.

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

The years in the corner give the window. While the window moves, the curve is Damodaran’s annual data throughout. The window then stops at 2021 to 2025, and the same portfolio appears measured monthly and then daily. The curve that moves through the windows is the one labelled “Annual” at the end.

What this does not settle

The rebalancing rule is annual and I do not vary it. Every curve measures a portfolio reset each 1 January. A different rule would give slightly different curves.

The y-axis is an arithmetic mean. It follows the FT and Markowitz. A 60/40 investor’s compound growth over 2021 to 2025 was lower.

Nothing here forecasts anything. Every curve here estimates one past sample, and none of them predicts the next five years.

What the two choices show

Historical stock-bond risk-return curves change with the sample window and with the return interval. A single curve, over a single five-year window, at a single frequency, is weak evidence that diversification has stopped working. That is a narrower claim than either “60/40 is dead” or “60/40 is fine”, and it is the one the data supports.

The takeaway is that before reading a frontier, ask which window and which interval produced it.