How did diversified portfolios do when US stocks rose or fell?

Python
Backtesting
Analysis
Code
From 1988 to 2026, three diversified portfolios typically lost less than the S&P 500 in its falling months and gained less in its rising months.
Published

June 3, 2026

If we worry that US stocks are overvalued, we can hold fewer of them. I compare how three diversified portfolios did against the S&P 500 in months when US stocks rose and in months when they fell, without testing whether stocks were overvalued. A portfolio with less in stocks typically lost less than the S&P 500 in its falling months and gained less in its rising ones. From 1988 to 2026, the All Weather portfolio earned 8.2% a year against 11.0% for the S&P 500, and its worst drawdown, the largest fall from a previous peak, was near 24% against 55%.

To see how much less each portfolio moves, I group the months by the S&P 500’s return and compare each portfolio’s median return in each group, the middle value when the group’s returns are sorted.

The portfolios

  • 60/40: 60% stocks, 40% 30-year Treasuries.
  • All Weather: 40% stocks, 30% gold, 30% Treasuries (10% each in 30-, 10-, and 1-year). It is loosely based on the idea behind Bridgewater’s All Weather portfolio: hold assets that respond differently to growth and inflation.
  • Risk parity: stocks, gold, and 30- and 10-year Treasuries, each weighted in proportion to 1 divided by its volatility, a measure of how much its daily returns swing, over up to a year of previous daily returns, recomputed each month. The code calls it naive risk parity, because the weights ignore how the assets move together.

The benchmark is 100% stocks, the S&P 500 total-return index, which includes reinvested dividends.

The setup

  • Assets: S&P 500 total return, gold, and 1-, 10-, and 30-year US Treasuries.
  • Returns: daily. Each portfolio is rebalanced to its target weights at the start of each month, and the weights change with prices within the month.
  • Two comparisons: full-sample risk and return, and each portfolio’s median return in each 2.5% bucket of the S&P 500’s monthly return.
  • Period: 1988 to 2026.

I use the median because the tail buckets are small and skewed, and one extreme month would move the mean too much.

Starting point

The backtest reads one in-memory panel, R, which I build in a separate job. The download and the cleaning are not on this page. The stock series is the same S&P 500 total-return index I used in an earlier post.

  • R: a Date × asset panel of daily total returns for the five assets: Stocks (S&P 500 total return), Gold, and three Treasuries T30, T10, T1. 1988 to 2026, returns in decimals, one row for each day on which at least one of the five assets has a price. I fill days an asset does not trade with a zero return, so all five share one calendar.

Parameters

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

TRADING_DAYS_PER_YEAR = 252   # used to put daily numbers on a yearly footing
VOL_LOOKBACK_DAYS = 252       # one year of daily returns behind each risk-parity weight
MIN_HISTORY_DAYS = 21         # fewest prior days a risk-parity weight is measured on

ALL_WEATHER = {'Stocks': 0.40, 'Gold': 0.30, 'T30': 0.10, 'T10': 0.10, 'T1': 0.10}
SIXTY_FORTY = {'Stocks': 0.60, 'T30': 0.40}
RISK_PARITY_ASSETS = ['Stocks', 'Gold', 'T30', 'T10']

Step 1. Build the portfolios

def backtest_drift(daily_returns, weight_rule):
    """Rebalance to the target weights at the start of each month, then let them change with prices.

    weight_rule(month, daily_returns, month_of_each_day) hands back the target weights
    for that month as a Series, or None when there is too little history to set them.
    """
    # A Period labels a whole month, so every trading day of March 1998 has the label 1998-03
    month_of_each_day = daily_returns.index.to_period('M')
    months = month_of_each_day.unique().sort_values()

    monthly_pieces = []
    for month in months:
        target_weights = weight_rule(month, daily_returns, month_of_each_day)
        if target_weights is None:
            continue                  # no weights for this month, so leave it out

        days_in_month = daily_returns.index[month_of_each_day == month]
        assets_held = list(target_weights.index)

        # fillna(0.0) makes a day an asset did not trade count as no move
        asset_returns = daily_returns.loc[days_in_month, assets_held].fillna(0.0)

        # Each column becomes one asset's wealth path inside the month, starting at 1
        asset_wealth = (1 + asset_returns).cumprod()

        # Multiplying a frame by an array aligns the array with the columns by
        # position, which is why the columns were picked in the weight order above.
        weighted_wealth = asset_wealth * target_weights.values
        portfolio_nav = weighted_wealth.sum(axis=1)

        # The month opens at a net asset value (NAV) of 1, so day one's return is measured
        # from 1. shift(1) moves the NAV down a row, so every later day is measured from
        # the day before.
        previous_nav = portfolio_nav.shift(1).fillna(1.0)
        daily_portfolio_return = portfolio_nav / previous_nav - 1.0

        monthly_pieces.append(daily_portfolio_return)

    # The pieces were built one month at a time, so put the days back in date order
    return pd.concat(monthly_pieces).sort_index()


# Every weight rule takes the same three arguments, so that backtest_drift can call any
# of them without knowing which it has. The three fixed portfolios ignore all three and
# return the same targets every month.

def stocks_only_weights(month, daily_returns, month_of_each_day):
    """The benchmark: the whole portfolio in the S&P 500 total-return index."""
    return pd.Series({'Stocks': 1.0})


def sixty_forty_weights(month, daily_returns, month_of_each_day):
    """Constant 60/40 targets, reset at the start of every month."""
    return pd.Series(SIXTY_FORTY)


def all_weather_weights(month, daily_returns, month_of_each_day):
    """Constant All Weather targets, reset at the start of every month."""
    return pd.Series(ALL_WEATHER)


def risk_parity_weights(month, daily_returns, month_of_each_day):
    """Naive risk parity: each asset weighted by 1 divided by its volatility."""
    # Strictly the months before this one, so that a return from the month being
    # weighted cannot enter the weights it is then measured with.
    prior_returns = daily_returns.loc[month_of_each_day < month, RISK_PARITY_ASSETS]
    if len(prior_returns) < MIN_HISTORY_DAYS:
        return None                   # too little history to measure volatility on

    recent_returns = prior_returns.tail(VOL_LOOKBACK_DAYS)
    volatility = recent_returns.std()

    # An asset with zero or missing volatility has no 1/volatility to be weighted by
    volatility = volatility[volatility > 0].dropna()

    inverse_volatility = 1.0 / volatility
    return inverse_volatility / inverse_volatility.sum()   # the weights sum to 1


portfolio_daily_returns = {
    'Stocks 100%': backtest_drift(R, stocks_only_weights),
    '60/40': backtest_drift(R, sixty_forty_weights),
    'All Weather': backtest_drift(R, all_weather_weights),
    'Naive risk parity': backtest_drift(R, risk_parity_weights),
}

Three design choices.

No rebalancing within the month. Weighting each asset’s compounded wealth path and adding the weighted paths gives the net asset value (NAV) of a portfolio set to its target weights on the first day of the month and then left to change with prices. Applying the target weights to each day’s returns instead would reset the weights every day, which is a daily rebalance.

No look-ahead on the weights. risk_parity_weights reads month_of_each_day < month, strictly the months before this one. January’s risk-parity weights use data through December.

Inverse-volatility weights. The risk-parity weights give the largest weight to the asset with the lowest volatility. Averaged over the months of this sample, they are near 18% stocks, 20% gold, 22% 30-year and 40% 10-year, so the two Treasuries together hold about 61% of the weight.

Step 2. Full-sample risk and return

Start with what each portfolio earned over the full sample, and at what risk.

def stats(daily_return):
    """Full-sample compound annual growth rate (CAGR), volatility, Sharpe ratio and worst drawdown."""
    daily_return = daily_return.dropna()
    n_days = len(daily_return)

    # The NAV over the full sample: one unit invested on the first day and compounded from there
    nav = (1 + daily_return).cumprod()
    final_nav = nav.iloc[-1]              # iloc[-1] is the last row, the closing wealth

    # Raising the total growth to the power 252 / n_days turns it into a rate per year,
    # counting 252 rows as one year. This panel has about 258 rows a year (see Limitations),
    # so this CAGR is slightly lower than one measured over calendar years.
    cagr = final_nav ** (TRADING_DAYS_PER_YEAR / n_days) - 1

    # Annualise the daily volatility with sqrt(252), which assumes daily returns are uncorrelated
    daily_volatility = daily_return.std()
    volatility = daily_volatility * np.sqrt(TRADING_DAYS_PER_YEAR)

    if daily_volatility > 0:
        sharpe = daily_return.mean() / daily_volatility * np.sqrt(TRADING_DAYS_PER_YEAR)
    else:
        sharpe = np.nan                   # a series that never moves has no ratio

    # cummax() is the highest NAV reached so far, so the distance below it is the drawdown
    running_peak = nav.cummax()
    drawdown = (nav - running_peak) / running_peak
    max_drawdown = float(drawdown.min())

    return cagr, volatility, sharpe, max_drawdown

Two lessons cover stats: Lesson 21 builds the NAV with cumprod, and Lesson 33 works through the Sharpe ratio and the CAGR, the compound annual growth rate. The Sharpe ratio in stats is the average daily return divided by its volatility, scaled to a year, with no risk-free rate, the return on cash, subtracted.

From March 1988, when risk parity starts, to January 2026, All Weather returned 8.2% a year and risk parity 6.7%, against 11.0% for the S&P 500. The S&P 500 fell further and earned less per unit of volatility: its worst drawdown was 55% and its Sharpe ratio 0.68. All Weather and risk parity had drawdowns near 24% and Sharpe ratios of 0.99. 60/40 is between the S&P 500 and those two.

Step 3. Performance in each return bucket

The full-sample numbers pool every month. To see how each portfolio performed at each level of the S&P 500 return, I sort the months into buckets 2.5% wide by that return and take each portfolio’s median return in each bucket.

BENCHMARK = 'Stocks 100%'
portfolio_names = ['60/40', 'All Weather', 'Naive risk parity']

BUCKET_HALF_WIDTH = 1.25      # a bucket is 2.5% wide, so it extends 1.25% either side
MIN_MONTHS_PER_BUCKET = 3     # a bucket with fewer months than this is left out


def to_monthly(daily_return):
    """Compound a series of daily returns into one return per calendar month."""
    # 'ME' groups the days by calendar month end, and prod() multiplies the daily
    # growth factors inside each month together.
    monthly_growth = (1 + daily_return).resample('ME').prod()
    monthly_return = monthly_growth - 1

    # A Period index labels the month as 2008-10, where resample('ME') labels it with a calendar month-end date
    monthly_return.index = monthly_return.index.to_period('M')
    return monthly_return


monthly_by_portfolio = {}
for name in [BENCHMARK] + portfolio_names:
    monthly_by_portfolio[name] = to_monthly(portfolio_daily_returns[name])

# axis=1 gives each portfolio its own column, aligned on the shared month index.
# dropna() then keeps the months every portfolio has, so the comparison starts where
# risk parity starts.
monthly_returns = pd.concat(monthly_by_portfolio, axis=1).dropna()

# The buckets are described in percentage points, so work in percent from here
sp500_monthly_pct = monthly_returns[BENCHMARK] * 100

# range() counts in whole numbers, so count the centres in tenths of a percent and
# divide down: -10, -7.5, -5, and so on up to +10.
bucket_centres = [tenths / 10 for tenths in range(-100, 101, 25)]

bucket_centres_kept = []
median_by_portfolio = {name: [] for name in portfolio_names}   # one list per portfolio

for centre in bucket_centres:
    # A bucket is centred on its label, so "-10%" covers -11.25% to -8.75%, the full
    # 2.5% window. The lower edge is open and the upper edge is closed, which stops a
    # month whose return equals an edge from counting in two buckets.
    above_lower_edge = sp500_monthly_pct > centre - BUCKET_HALF_WIDTH
    at_or_below_upper_edge = sp500_monthly_pct <= centre + BUCKET_HALF_WIDTH
    in_bucket = above_lower_edge & at_or_below_upper_edge

    if in_bucket.sum() < MIN_MONTHS_PER_BUCKET:
        continue

    bucket_centres_kept.append(centre)
    for name in portfolio_names:
        returns_in_bucket = monthly_returns.loc[in_bucket, name]
        median_by_portfolio[name].append(returns_in_bucket.median() * 100)

The loop leaves one bucket centre and one median per portfolio for every bucket kept. The chart plots each portfolio’s medians against the bucket centres, with a dashed 1:1 reference line on which a median equals its bucket centre.

from matplotlib.ticker import MultipleLocator
from matplotlib.lines import Line2D

# One colour, one line style and one marker per portfolio, held in three lookups so
# that each drawing line below reads as the one thing it sets.
LINE_COLOUR = {'60/40': '#009E73', 'All Weather': '#0072B2', 'Naive risk parity': '#D55E00'}
LINE_STYLE = {'60/40': '-.', 'All Weather': '-', 'Naive risk parity': ':'}
MARKER = {'60/40': 'o', 'All Weather': 's', 'Naive risk parity': 'D'}

fig, ax = plt.subplots(figsize=(10, 6.2))

# Draw the dashed 1:1 reference line first, so that the portfolio lines are drawn on top of it.
# On this line a median equals its bucket centre.
lowest_centre = min(bucket_centres_kept)
highest_centre = max(bucket_centres_kept)
ax.plot([lowest_centre, highest_centre], [lowest_centre, highest_centre],
        color='#888', lw=1.7, ls='--')

for name in portfolio_names:
    ax.plot(bucket_centres_kept, median_by_portfolio[name],
            marker=MARKER[name], color=LINE_COLOUR[name], ls=LINE_STYLE[name],
            lw=2.6, ms=7)

ax.axhline(0, color='#444', lw=1.3)
ax.axvline(0, color='#bbb', lw=0.9)
ax.xaxis.set_major_locator(MultipleLocator(2.5))
ax.yaxis.set_major_locator(MultipleLocator(2.5))
ax.set_xlim(-11, 11)
ax.set_xlabel('S&P 500 monthly return (%)')
ax.set_ylabel('Median portfolio monthly return (%)')
ax.set_title('Portfolio performance in each S&P 500 monthly-return bucket')

# The lines above were drawn without labels, so build one legend entry for each by hand
legend_handles = []
for name in portfolio_names:
    legend_handles.append(Line2D([0], [0], color=LINE_COLOUR[name], ls=LINE_STYLE[name],
                                 marker=MARKER[name], lw=2.6, ms=7, label=name))
legend_handles.append(Line2D([0], [0], color='#888', ls='--', lw=1.7, label='S&P 500 (1:1)'))

ax.legend(handles=legend_handles, loc='upper left')
plt.tight_layout()
plt.show()

A portfolio with the same return as the S&P 500 would be within 1.25 percentage points of the grey dashed 1:1 line, half the width of a bucket. Every portfolio line is flatter, because each portfolio holds less in stocks, so its return responds less to the S&P 500 in both directions. In the months when the S&P 500 fell about 10%, the median All Weather return was about -3% and the median risk-parity return about zero. In the months when it rose about 10%, they were about +3% and +2%.

Results

In every bucket except the one around zero, the median returns of the three portfolios were closer to zero than the bucket centre: they typically lost less than the S&P 500 in its falling months and gained less in its rising ones. In this sample, the smaller moves came with a lower return: All Weather earned about three percentage points a year less than the S&P 500, and risk parity about four. Whether the flatter line is worth the lower return is our own judgement.

Limitations

Gross of costs, unlevered. No commissions, spreads or rebalancing costs. Risk parity here is unlevered. A levered version would raise the return and add financing and margin risk, and this post models neither.

Buckets are medians. Each point is a median, so it does not show the spread within its bucket, and each bucket covers a range of monthly outcomes. In a single month, a portfolio can still lose more than the S&P 500.

Largest months left out. The buckets cover S&P 500 monthly returns from -11.25% to +11.25%, so the chart leaves out the five months outside that range, among them October 2008.

Rows per year. The panel has about 258 rows a year, because it keeps the days on which only gold or the Treasuries have a price, mostly US holidays. stats counts 252 rows as a year, so every CAGR in this post is 0.15 to 0.26 percentage points lower than one measured over calendar years: about 11.3% for the S&P 500 and 8.4% for All Weather. The volatilities and Sharpe ratios are about 1% lower for the same reason. The gaps between the portfolios barely change.

Sample start. The S&P 500 total-return series only goes back to 1988, so the backtest starts there.

Conclusion

The takeaway is that, from 1988 to 2026, holding less in stocks meant smaller losses in the S&P 500’s falling months, smaller gains in its rising months and a lower return overall: 8.2% a year for All Weather against 11.0% for the S&P 500.

Disclaimer: simplified, hypothetical backtest, gross of costs and unlevered, for discussion purposes only. Not investment advice. Past performance does not guarantee future returns.