Skip to main content

Performance Of Various Asset Classes During Fed Policy Cycles

··8539 words·41 mins

Introduction #

In this post, we will look into the Fed Funds cycles and evaluate asset class performance during tightening and easing of monetary policy.

Python Imports #

# Standard Library
import datetime
import os
import sys
import warnings

from datetime import datetime
from pathlib import Path

# Data Handling
import numpy as np
import pandas as pd

# Data Sources
import pandas_datareader.data as web

# Statistical Analysis
import statsmodels.api as sm

# Machine Learning
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler

# Suppress warnings
warnings.filterwarnings("ignore")

Add Directories To Path #

# Add the source subdirectory to the system path to allow import config from settings.py
current_directory = Path(os.getcwd())
website_base_directory = current_directory.parent.parent.parent
src_directory = website_base_directory / "src"
sys.path.append(str(src_directory)) if str(src_directory) not in sys.path else None

# Import settings.py
from settings import config

# Add configured directories from config to path
SOURCE_DIR = config("SOURCE_DIR")
sys.path.append(str(Path(SOURCE_DIR))) if str(Path(SOURCE_DIR)) not in sys.path else None

# Add other configured directories
BASE_DIR = config("BASE_DIR")
CONTENT_DIR = config("CONTENT_DIR")
POSTS_DIR = config("POSTS_DIR")
PAGES_DIR = config("PAGES_DIR")
PUBLIC_DIR = config("PUBLIC_DIR")
SOURCE_DIR = config("SOURCE_DIR")
DATA_DIR = config("DATA_DIR")
DATA_MANUAL_DIR = config("DATA_MANUAL_DIR")

Python Functions #

Here are the functions needed for this project:

  • bb_clean_data: Takes an Excel export from Bloomberg, removes the miscellaneous headings/rows, and returns a DataFrame.
  • calc_fed_cycle_asset_performance: Calculates metrics for an asset based on a specified Fed tightening/loosening cycle.
  • load_data: Load data from a CSV, Excel, or Pickle file into a pandas DataFrame.
  • pandas_set_decimal_places: Set the number of decimal places displayed for floating-point numbers in pandas.
  • plot_bar_returns_ffr_change: Plot the bar chart of the cumulative or annualized returns for the asset class along with the change in the Fed Funds Rate.
  • plot_scatter_regression_ffr_vs_returns: Plot the scatter plot and regression of the annualized return for the asset class along with the annualized change in the Fed Funds Rate.
  • plot_timeseries: Plot the timeseries data from a DataFrame for a specified date range and columns.
  • summary_stats: Generate summary statistics for a series of returns.
from bb_clean_data import bb_clean_data
from calc_fed_cycle_asset_performance import calc_fed_cycle_asset_performance
from load_data import load_data
from pandas_set_decimal_places import pandas_set_decimal_places
from plot_bar_returns_ffr_change import plot_bar_returns_ffr_change
from plot_scatter_regression_ffr_vs_returns import plot_scatter_regression_ffr_vs_returns
from plot_timeseries import plot_timeseries
from summary_stats import summary_stats

Data Overview #

Acquire & Plot Fed Funds Data #

First, let’s get the data for the Fed Funds target rate (FFR). This data is found in 3 different datasets from FRED.

# Set decimal places
pandas_set_decimal_places(5)

# Pull Federal Funds Target Rate (DISCONTINUED) (DFEDTAR)
fedfunds_target_old = web.DataReader("DFEDTAR", "fred", start="1900-01-01", end=datetime.today())

# Pull Federal Funds Target Range - Upper Limit (DFEDTARU)
fedfunds_target_new_upper = web.DataReader("DFEDTARU", "fred", start="1900-01-01", end=datetime.today())

# Pull Federal Funds Target Range - Lower Limit (DFEDTARL)
fedfunds_target_new_lower = web.DataReader("DFEDTARL", "fred", start="1900-01-01", end=datetime.today())

# Merge the datasets together
fedfunds_combined = pd.concat([fedfunds_target_old, fedfunds_target_new_upper, fedfunds_target_new_lower], axis=1)

# Divide all values by 100 to get decimal rates
for col in fedfunds_combined.columns:
    fedfunds_combined[col] = fedfunds_combined[col] / 100

# Resample to month-end (as if we know the rate at the end of the month)
fedfunds_monthly = fedfunds_combined.resample("ME").last()

display(fedfunds_monthly)

DFEDTARDFEDTARUDFEDTARL
DATE
1982-09-300.10250NaNNaN
1982-10-310.09500NaNNaN
1982-11-300.09000NaNNaN
1982-12-310.08500NaNNaN
1983-01-310.08500NaNNaN
............
2025-10-31NaN0.040000.03750
2025-11-30NaN0.040000.03750
2025-12-31NaN0.037500.03500
2026-01-31NaN0.037500.03500
2026-02-28NaN0.037500.03500

522 rows × 3 columns

We can then generate several useful plots. First, the Fed Funds target rate:

plot_timeseries(
    price_df=fedfunds_monthly,
    plot_start_date=None,
    plot_end_date=None,
    plot_columns=["DFEDTAR", "DFEDTARU", "DFEDTARL"],
    title="Fed Funds Target Rate",
    x_label="Date",
    x_format="Year",
    x_tick_spacing=2,
    x_tick_rotation=45,
    y_label="Rate (%)",
    y_format="Percentage",
    y_format_decimal_places=0,
    y_tick_spacing=0.01,
    grid=True,
    legend=True,
    export_plot=False,
    plot_file_name=None,
)

png

# First drop the lower column and merge
fedfunds_monthly["fed_funds"] = fedfunds_monthly["DFEDTAR"].combine_first(fedfunds_monthly["DFEDTARU"])
fedfunds_monthly = fedfunds_monthly.drop(columns=["DFEDTARL", "DFEDTARU", "DFEDTAR"])

# Compute change in rate from either column
fedfunds_monthly["fed_funds_change"] = fedfunds_monthly["fed_funds"].diff()

display(fedfunds_monthly)

fed_fundsfed_funds_change
DATE
1982-09-300.10250NaN
1982-10-310.09500-0.00750
1982-11-300.09000-0.00500
1982-12-310.08500-0.00500
1983-01-310.085000.00000
.........
2025-10-310.04000-0.00250
2025-11-300.040000.00000
2025-12-310.03750-0.00250
2026-01-310.037500.00000
2026-02-280.037500.00000

522 rows × 2 columns

And then the change in FFR from month-to-month:

plot_timeseries(
    price_df=fedfunds_monthly,
    plot_start_date=None,
    plot_end_date=None,
    plot_columns=["fed_funds_change"],
    title="Fed Funds Change In Rate",
    x_label="Date",
    x_format="Year",
    x_tick_spacing=2,
    x_tick_rotation=45,
    y_label="Rate Change (%)",
    y_format="Percentage",
    y_format_decimal_places=2,
    y_tick_spacing=0.0025,
    grid=True,
    legend=False,
    export_plot=False,
    plot_file_name=None,
)

png

This plot, in particular, makes it easy to show the monthly increase and decrease in the FFR, as well as the magnitude of the change (i.e. slow, drawn-out increases or decreases or abrupt large increases or decreases).

Define Fed Policy Cycles #

Next, we will define the Fed policy tightening and easing cycles:

# Set timeframe
start_date = "1989-12-15"
end_date = "2026-01-31"

# Copy DataFrame to avoid modifying original
fedfunds_cycles = fedfunds_monthly.copy()
fedfunds_cycles = fedfunds_cycles.loc[start_date:end_date]

# Reset index
fedfunds_cycles = fedfunds_cycles.reset_index()

# Drop the "fed_funds_change" column as we will use it to determine the cycle type later, but we don't need it in the final dataframe
fedfunds_cycles = fedfunds_cycles.drop(columns=["fed_funds_change"])

# Rename the date column to "Start Date", and the "fed_funds" column to "Fed Funds Start"
fedfunds_cycles = fedfunds_cycles.rename(columns={"DATE": "start_date", "fed_funds": "fed_funds_start"})

# Copy the "Start Date" column to a new column called "End Date" and shift it up by one row
fedfunds_cycles["end_date"] = fedfunds_cycles["start_date"].shift(-1)

# Copy the "Fed Funds Start" column to a new column called "Fed Funds End" and shift it up by one row
fedfunds_cycles["fed_funds_end"] = fedfunds_cycles["fed_funds_start"].shift(-1)

# Calculate the change in Fed Funds rate for each cycle
fedfunds_cycles["fed_funds_change"] = fedfunds_cycles["fed_funds_end"] - fedfunds_cycles["fed_funds_start"]

# Based on "Fed Funds Change" column, determine if the rate is increasing, decreasing, or unchanged
fedfunds_cycles["cycle"] = fedfunds_cycles["fed_funds_change"].apply(
    lambda x: "Tightening" if x > 0 else ("Easing" if x < 0 else "Neutral"))

display(fedfunds_cycles)

start_datefed_funds_startend_datefed_funds_endfed_funds_changecycle
01989-12-310.082501990-01-310.082500.00000Neutral
11990-01-310.082501990-02-280.082500.00000Neutral
21990-02-280.082501990-03-310.082500.00000Neutral
31990-03-310.082501990-04-300.082500.00000Neutral
41990-04-300.082501990-05-310.082500.00000Neutral
.....................
4292025-09-300.042502025-10-310.04000-0.00250Easing
4302025-10-310.040002025-11-300.040000.00000Neutral
4312025-11-300.040002025-12-310.03750-0.00250Easing
4322025-12-310.037502026-01-310.037500.00000Neutral
4332026-01-310.03750NaTNaNNaNNeutral

434 rows × 6 columns

Grouping the consecutive months of tightening and easing together, we can create a DataFrame that gives us the cumulative change in the FFR for each cycle, as well as the start and end dates for each cycle. This will be useful for evaluating asset class performance during these cycles.

fedfunds_grouped_cycles = fedfunds_cycles.copy()

# Create a group key that increments whenever the Cycle value changes
fedfunds_grouped_cycles['group'] = (fedfunds_grouped_cycles['cycle'] != fedfunds_grouped_cycles['cycle'].shift(1)).cumsum()

# Group by both the group key and Cycle label
cycle_ranges = (
    fedfunds_grouped_cycles.groupby(['group', 'cycle'], sort=False)
    .agg(
        start_date=('start_date', 'first'),
        end_date=('end_date', 'last'),
        fed_funds_start=('fed_funds_start', 'first'),
        fed_funds_end=('fed_funds_end', 'last')
    )
    .reset_index(drop=False)
    .drop(columns='group')
)

display(cycle_ranges)

cyclestart_dateend_datefed_funds_startfed_funds_end
0Neutral1989-12-311990-06-300.082500.08250
1Easing1990-06-301990-07-310.082500.08000
2Neutral1990-07-311990-09-300.080000.08000
3Easing1990-09-301991-04-300.080000.05750
4Neutral1991-04-301991-07-310.057500.05750
..................
116Neutral2024-12-312025-08-310.045000.04500
117Easing2025-08-312025-10-310.045000.04000
118Neutral2025-10-312025-11-300.040000.04000
119Easing2025-11-302025-12-310.040000.03750
120Neutral2025-12-312026-01-310.037500.03750

121 rows × 5 columns

Furthermore, we will make the assumption that any “Neutral” months (i.e. months where the FFR did not change) are part of the preceding cycle – to a point. For example, if we have a tightening month followed by a neutral month, we will consider the neutral month to be part of the tightening cycle. This is a simplifying assumption, but it allows us to categorize all months into either tightening or easing cycles without having to create a separate category for neutral months. From a practical standpoint, this also makes it easier to evaluate asset class performance during these cycles, as we can simply look at the performance during the tightening and easing cycles without having to worry about the neutral months. We will foward fill no more than 6 months of neutral months, however, as it would be unreasonable to assume that a month of neutral policy is part of the preceding cycle if it has been 6 months since the last change in policy.

fedfunds_grouped_cycles = fedfunds_cycles.copy()

# Forward fill cycle labels preceding "Neutral" cycles
fedfunds_grouped_cycles['cycle_filled'] = fedfunds_grouped_cycles['cycle'].replace('Neutral', pd.NA).ffill(limit=6)

# Replaced any remaining <NA> values with "Modified Tightening"
fedfunds_grouped_cycles['cycle_filled'] = fedfunds_grouped_cycles['cycle_filled'].fillna('Modified Tightening')

# Create a group key that increments whenever the Cycle value changes
fedfunds_grouped_cycles['group'] = (fedfunds_grouped_cycles['cycle_filled'] != fedfunds_grouped_cycles['cycle_filled'].shift(1)).cumsum()

# Group by both the group key and Cycle label
cycle_ranges = (
    fedfunds_grouped_cycles.groupby(['group', 'cycle_filled'], sort=False)
    .agg(
        start_date=('start_date', 'first'),
        end_date=('end_date', 'last'),
        fed_funds_start=('fed_funds_start', 'first'),
        fed_funds_end=('fed_funds_end', 'last')
    )
    .reset_index(drop=False)
    .drop(columns='group')
)

# Calc change in Fed Funds rate for each cycle
cycle_ranges["fed_funds_change"] = cycle_ranges["fed_funds_end"] - cycle_ranges["fed_funds_start"]

# Add cycle labels
cycle_labels = [f"Cycle {i+1}" for i in range(len(cycle_ranges))]

# Combine labels with cycle_ranges
cycle_ranges['cycle_label'] = cycle_labels

display(cycle_ranges)

cycle_filledstart_dateend_datefed_funds_startfed_funds_endfed_funds_changecycle_label
0Modified Tightening1989-12-311990-06-300.082500.082500.00000Cycle 1
1Easing1990-06-301993-03-310.082500.03000-0.05250Cycle 2
2Modified Tightening1993-03-311994-01-310.030000.030000.00000Cycle 3
3Tightening1994-01-311995-06-300.030000.060000.03000Cycle 4
4Easing1995-06-301996-07-310.060000.05250-0.00750Cycle 5
5Modified Tightening1996-07-311997-02-280.052500.052500.00000Cycle 6
6Tightening1997-02-281997-09-300.052500.055000.00250Cycle 7
7Modified Tightening1997-09-301998-08-310.055000.055000.00000Cycle 8
8Easing1998-08-311999-05-310.055000.04750-0.00750Cycle 9
9Tightening1999-05-312000-11-300.047500.065000.01750Cycle 10
10Modified Tightening2000-11-302000-12-310.065000.065000.00000Cycle 11
11Easing2000-12-312002-06-300.065000.01750-0.04750Cycle 12
12Modified Tightening2002-06-302002-10-310.017500.017500.00000Cycle 13
13Easing2002-10-312003-12-310.017500.01000-0.00750Cycle 14
14Modified Tightening2003-12-312004-05-310.010000.010000.00000Cycle 15
15Tightening2004-05-312006-12-310.010000.052500.04250Cycle 16
16Modified Tightening2006-12-312007-08-310.052500.052500.00000Cycle 17
17Easing2007-08-312009-07-310.052500.00250-0.05000Cycle 18
18Modified Tightening2009-07-312015-11-300.002500.002500.00000Cycle 19
19Tightening2015-11-302016-06-300.002500.005000.00250Cycle 20
20Modified Tightening2016-06-302016-11-300.005000.005000.00000Cycle 21
21Tightening2016-11-302019-06-300.005000.025000.02000Cycle 22
22Modified Tightening2019-06-302019-07-310.025000.025000.00000Cycle 23
23Easing2019-07-312020-09-300.025000.00250-0.02250Cycle 24
24Modified Tightening2020-09-302022-02-280.002500.002500.00000Cycle 25
25Tightening2022-02-282024-01-310.002500.055000.05250Cycle 26
26Modified Tightening2024-01-312024-08-310.055000.055000.00000Cycle 27
27Easing2024-08-312025-06-300.055000.04500-0.01000Cycle 28
28Modified Tightening2025-06-302025-08-310.045000.045000.00000Cycle 29
29Easing2025-08-312026-01-310.045000.03750-0.00750Cycle 30

Asset Class Performance By Fed Policy Cycle #

Moving on, we will now look at the performance of four (4) different asset classes during each Fed cycle. We’ll use the following Bloomberg indices:

  • SPXT_S&P 500 Total Return Index (Stocks)
  • SPBDU10T_S&P US Treasury Bond 7-10 Year Total Return Index (Bonds)
  • LF98TRUU_Bloomberg US Corporate High Yield Total Return Index Value Unhedged USD (High Yield Bonds)
  • XAU_Gold USD Spot (Gold)

Stocks #

First, we will clean and load the data for the S&P 500 Total Return Index (SPXT):

bb_clean_data(
    base_directory=DATA_DIR,
    fund_ticker_name="SPXT_S&P 500 Total Return Index",
    source="Bloomberg",
    asset_class="Indices",
    excel_export=True,
    pickle_export=True,
    output_confirmation=False,
)

spxt = load_data(
    base_directory=DATA_DIR,
    ticker="SPXT_S&P 500 Total Return Index_Clean",
    source="Bloomberg", 
    asset_class="Indices",
    timeframe="Daily",
    file_format="pickle",
)

# Filter SPXT to date range
spxt = spxt[(spxt.index >= pd.to_datetime(start_date)) & (spxt.index <= pd.to_datetime(end_date))]

# Drop everything except the "close" column
spxt = spxt[["Close"]]

# Resample to monthly frequency
spxt_monthly = spxt.resample("M").last()
spxt_monthly["Monthly_Return"] = spxt_monthly["Close"].pct_change()
spxt_monthly = spxt_monthly.dropna()

display(spxt_monthly)

CloseMonthly_Return
Date
1990-01-31353.94000-0.06713
1990-02-28358.500000.01288
1990-03-313680.02650
1990-04-30358.81000-0.02497
1990-05-31393.800000.09752
.........
2025-09-3014826.800000.03650
2025-10-3115173.950000.02341
2025-11-3015211.140000.00245
2025-12-3115220.450000.00061
2026-01-3115441.150000.01450

433 rows × 2 columns

Next, we can plot the price history before calculating the cycle performance:

plot_timeseries(
    price_df=spxt,
    plot_start_date=None,
    plot_end_date=None,
    plot_columns=["Close"],
    title="S&P 500 Total Return Index Daily Close Price",
    x_label="Date",
    x_format="Year",
    x_tick_spacing=2,
    x_tick_rotation=45,
    y_label="Price ($)",
    y_format="Decimal",
    y_format_decimal_places=0,
    y_tick_spacing=1000,
    grid=True,
    legend=False,
    export_plot=False,
    plot_file_name=None,
)

png

Next, we will calculate the performance for SPY based on the pre-defined Fed cycles:

spxt_cycles = calc_fed_cycle_asset_performance(
    start_date=cycle_ranges["start_date"],
    end_date=cycle_ranges["end_date"],
    label=cycle_ranges["cycle_label"],
    fed_funds_change=cycle_ranges["fed_funds_change"],
    monthly_returns=spxt_monthly,
)

display(spxt_cycles)

CycleStartEndMonthsCumulativeReturnCumulativeReturnPctAverageMonthlyReturnAverageMonthlyReturnPctAnnualizedReturnAnnualizedReturnPctVolatilityFedFundsChangeFedFundsChange_bpsFFR_AnnualizedChangeFFR_AnnualizedChange_bpsLabel
0Cycle 11989-12-311990-06-3060.030923.091640.006340.634030.062796.278870.191700.000000.000000.000000.00000Cycle 1, 1989-12-31 to 1990-06-30
1Cycle 21990-06-301993-03-31340.3680036.800410.009960.995730.1169411.694260.13198-0.05250-525.00000-0.01853-185.29412Cycle 2, 1990-06-30 to 1993-03-31
2Cycle 31993-03-311994-01-31110.1135911.359200.010011.000890.1245412.453750.069180.000000.000000.000000.00000Cycle 3, 1993-03-31 to 1994-01-31
3Cycle 41994-01-311995-06-30180.2180021.800420.011411.140600.1405114.051030.099280.03000300.000000.02000200.00000Cycle 4, 1994-01-31 to 1995-06-30
4Cycle 51995-06-301996-07-31140.2323023.230230.015271.527040.1960719.607290.07840-0.00750-75.00000-0.00643-64.28571Cycle 5, 1995-06-30 to 1996-07-31
5Cycle 61996-07-311997-02-2880.1959219.591520.023362.335760.3078330.782780.143610.000000.000000.000000.00000Cycle 6, 1996-07-31 to 1997-02-28
6Cycle 71997-02-281997-09-3080.2201722.017130.026312.630560.3478234.781780.175470.0025025.000000.0037537.50000Cycle 7, 1997-02-28 to 1997-09-30
7Cycle 81997-09-301998-08-31120.080948.094340.008120.812420.080948.094340.200280.000000.000000.000000.00000Cycle 8, 1997-09-30 to 1998-08-31
8Cycle 91998-08-311999-05-31100.1755317.553340.018491.849150.2141821.417690.23543-0.00750-75.00000-0.00900-90.00000Cycle 9, 1998-08-31 to 1999-05-31
9Cycle 101999-05-312000-11-30190.004010.401400.001270.127000.002530.253330.164830.01750175.000000.01105110.52632Cycle 10, 1999-05-31 to 2000-11-30
10Cycle 112000-11-302000-12-312-0.07433-7.43308-0.03697-3.69725-0.37088-37.087810.205110.000000.000000.000000.00000Cycle 11, 2000-11-30 to 2000-12-31
11Cycle 122000-12-312002-06-3019-0.23106-23.10629-0.01254-1.25394-0.15291-15.290710.17313-0.04750-475.00000-0.03000-300.00000Cycle 12, 2000-12-31 to 2002-06-30
12Cycle 132002-06-302002-10-315-0.16407-16.40672-0.03266-3.26564-0.34955-34.955390.276160.000000.000000.000000.00000Cycle 13, 2002-06-30 to 2002-10-31
13Cycle 142002-10-312003-12-31150.3954339.542920.023252.325210.3054730.546810.14377-0.00750-75.00000-0.00600-60.00000Cycle 14, 2002-10-31 to 2003-12-31
14Cycle 152003-12-312004-05-3160.067926.791520.011271.127200.1404414.044290.087370.000000.000000.000000.00000Cycle 15, 2003-12-31 to 2004-05-31
15Cycle 162004-05-312006-12-31320.3457234.571660.009520.951920.1177811.778330.069980.04250425.000000.01594159.37500Cycle 16, 2004-05-31 to 2006-12-31
16Cycle 172006-12-312007-08-3190.066716.671030.007480.748170.089928.992170.087220.000000.000000.000000.00000Cycle 17, 2006-12-31 to 2007-08-31
17Cycle 182007-08-312009-07-3124-0.28840-28.83990-0.01191-1.19125-0.15644-15.643550.22888-0.05000-500.00000-0.02500-250.00000Cycle 18, 2007-08-31 to 2009-07-31
18Cycle 192009-07-312015-11-30771.59039159.039050.013141.314140.1599015.990000.131250.000000.000000.000000.00000Cycle 19, 2009-07-31 to 2015-11-30
19Cycle 202015-11-302016-06-3080.025022.502500.003560.356170.037773.777140.113890.0025025.000000.0037537.50000Cycle 20, 2015-11-30 to 2016-06-30
20Cycle 212016-06-302016-11-3060.060086.007660.009970.997440.1237612.376230.077080.000000.000000.000000.00000Cycle 21, 2016-06-30 to 2016-11-30
21Cycle 222016-11-302019-06-30320.4603146.030910.012561.256180.1525715.256860.126850.02000200.000000.0075075.00000Cycle 22, 2016-11-30 to 2019-06-30
22Cycle 232019-06-302019-07-3120.085868.586280.042424.242490.6392763.926720.137430.000000.000000.000000.00000Cycle 23, 2019-06-30 to 2019-07-31
23Cycle 242019-07-312020-09-30150.1710517.104560.012341.234240.1346413.464250.21140-0.02250-225.00000-0.01800-180.00000Cycle 24, 2019-07-31 to 2020-09-30
24Cycle 252020-09-302022-02-28180.2772827.728440.014571.456700.1772217.722210.150780.000000.000000.000000.00000Cycle 25, 2020-09-30 to 2022-02-28
25Cycle 262022-02-282024-01-31240.1089210.891960.005840.583630.053055.305250.194800.05250525.000000.02625262.50000Cycle 26, 2022-02-28 to 2024-01-31
26Cycle 272024-01-312024-08-3180.1952619.525880.022932.292820.3067530.675130.102380.000000.000000.000000.00000Cycle 27, 2024-01-31 to 2024-08-31
27Cycle 282024-08-312025-06-30110.1377913.778690.012441.244360.1512215.121750.13032-0.01000-100.00000-0.01091-109.09091Cycle 28, 2024-08-31 to 2025-06-30
28Cycle 292025-06-302025-08-3130.096229.621710.031193.118900.4440644.406370.059110.000000.000000.000000.00000Cycle 29, 2025-06-30 to 2025-08-31
29Cycle 302025-08-312026-01-3160.1013310.132980.016291.629140.2129321.292730.04688-0.00750-75.00000-0.01500-150.00000Cycle 30, 2025-08-31 to 2026-01-31

This gives us the following data points:

  • Cycle start date
  • Cycle end date
  • Number of months in the cycle
  • Cumulative return during the cycle (decimal and percent)
  • Average monthly return during the cycle (decimal and percent)
  • Annualized return during the cycle (decimal and percent)
  • Return volatility during the cycle
  • Cumulative change in FFR during the cycle (decimal and basis points)
  • Annualized change in FFR during the cycle (decimal and basis points)

From the above DataFrame, we can then plot the cumulative and annualized returns for each cycle in a bar chart. First, the cumulative returns along with the cumulative change in FFR:

plot_bar_returns_ffr_change(
    cycle_df=spxt_cycles,
    asset_label="SPXT",
    annualized_or_cumulative="Cumulative",
)

png

And then the annualized returns along with the annualized change in FFR:

plot_bar_returns_ffr_change(
    cycle_df=spxt_cycles,
    asset_label="SPXT",
    annualized_or_cumulative="Annualized",
)

png

The cumulative returns plot is not particularly insightful, but there are some interesting observations to be gained from the annualized returns plot. During the past two (2) rate cutting cycles (cycles 11/12/13/14 and 18), stocks have exhibited negative returns during the rate cutting cycle. However, after the rate cutting cycle was complete, returns during the following cycle (when rates were usually flat) were quite strong and higher than the historical mean return for the S&P 500. The economic intuition for this behavior is valid; as the economy weakens, investors are concerned about the pricing of equities, the returns become negative, and the Fed responds with cutting rates. The exact timing of when the Fed begins cutting rates is one of the unknowns; the Fed could be ahead of the curve, cutting rates as economic data begins to prompt that action, or behind the curve, where the ecomony rolls over rapidly and even the Fed’s actions are not enough to halt the economic contraction.

Finally, we can run an OLS regression to check fit:

df = spxt_cycles

#=== Don't modify below this line ===

# Run OLS regression with statsmodels
X = df["FFR_AnnualizedChange_bps"]
y = df["AnnualizedReturnPct"]
X = sm.add_constant(X)
model = sm.OLS(y, X).fit()
print(model.summary())
print(f"Intercept: {model.params[0]}, Slope: {model.params[1]}")  # Intercept and slope

# Calc X and Y values for regression line
X_vals = np.linspace(X.min(), X.max(), 100)
Y_vals = model.params[0] + model.params[1] * X_vals
                             OLS Regression Results                            
===============================================================================
Dep. Variable:     AnnualizedReturnPct   R-squared:                       0.017
Model:                             OLS   Adj. R-squared:                 -0.018
Method:                  Least Squares   F-statistic:                    0.4786
Date:                 Tue, 24 Feb 2026   Prob (F-statistic):              0.495
Time:                         14:11:31   Log-Likelihood:                -132.24
No. Observations:                   30   AIC:                             268.5
Df Residuals:                       28   BIC:                             271.3
Df Model:                            1                                         
Covariance Type:             nonrobust                                         
============================================================================================
                               coef    std err          t      P>|t|      [0.025      0.975]
--------------------------------------------------------------------------------------------
const                       13.0760      3.792      3.448      0.002       5.308      20.844
FFR_AnnualizedChange_bps     0.0221      0.032      0.692      0.495      -0.043       0.087
==============================================================================
Omnibus:                        4.070   Durbin-Watson:                   1.523
Prob(Omnibus):                  0.131   Jarque-Bera (JB):                2.894
Skew:                          -0.320   Prob(JB):                        0.235
Kurtosis:                       4.381   Cond. No.                         120.
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.
Intercept: 13.076015539043272, Slope: 0.022056651501767076

And then plot the regression line along with the values:

plot_scatter_regression_ffr_vs_returns(
    cycle_df=spxt_cycles,
    asset_label="SPXT",
    x_vals=X_vals,
    y_vals=Y_vals,
    intercept=model.params[0],
    slope=model.params[1],
)

png

Here we can see the data points for cycles 11/12/13/14 and 18 as mentioned above. Interestingly, cycles 28/29/30 (which is the current rate cutting cycle) appears to be an outlier. Of course, the book is not yet finished for cycles 28/29/30, and we could certainly see a bear market in stocks over the next several years.

Bonds #

Next, we’ll run a similar process for medium term bonds using SPBDU10T_S&P US Treasury Bond 7-10 Year Total Return Index.

First, we pull data with the following:

bb_clean_data(
    base_directory=DATA_DIR,
    fund_ticker_name="SPBDU10T_S&P US Treasury Bond 7-10 Year Total Return Index",
    source="Bloomberg",
    asset_class="Indices",
    excel_export=True,
    pickle_export=True,
    output_confirmation=False,
)

treas_10y = load_data(
    base_directory=DATA_DIR,
    ticker="SPBDU10T_S&P US Treasury Bond 7-10 Year Total Return Index_Clean",
    source="Bloomberg", 
    asset_class="Indices",
    timeframe="Daily",
    file_format="pickle",
)

# Filter TREAS_10Y to date range
treas_10y = treas_10y[(treas_10y.index >= pd.to_datetime(start_date)) & (treas_10y.index <= pd.to_datetime(end_date))]

# Drop everything except the "close" column
treas_10y = treas_10y[["Close"]]

# Resample to monthly frequency
treas_10y_monthly = treas_10y.resample("M").last()
treas_10y_monthly["Monthly_Return"] = treas_10y_monthly["Close"].pct_change()
treas_10y_monthly = treas_10y_monthly.dropna()

display(treas_10y_monthly)

CloseMonthly_Return
Date
1990-01-3198.01300-0.01987
1990-02-2897.99000-0.00023
1990-03-3197.98900-0.00001
1990-04-3096.60600-0.01411
1990-05-3199.647000.03148
.........
2025-09-30645.584000.00675
2025-10-31650.005000.00685
2025-11-30656.636000.01020
2025-12-31652.28800-0.00662
2026-01-31650.16900-0.00325

433 rows × 2 columns

Next, we can plot the price history before calculating the cycle performance:

plot_timeseries(
    price_df=treas_10y,
    plot_start_date=None,
    plot_end_date=None,
    plot_columns=["Close"],
    title="S&P US Treasury Bond 7-10 Year Total Return Index Daily Close Price",
    x_label="Date",
    x_format="Year",
    x_tick_spacing=2,
    x_tick_rotation=45,
    y_label="Price ($)",
    y_format="Decimal",
    y_format_decimal_places=0,
    y_tick_spacing=50,
    grid=True,
    legend=False,
    export_plot=False,
    plot_file_name=None,
)

png

Next, we will calculate the performance for SPY based on the pre-defined Fed cycles:

treas_10y_cycles = calc_fed_cycle_asset_performance(
    start_date=cycle_ranges["start_date"],
    end_date=cycle_ranges["end_date"],
    label=cycle_ranges["cycle_label"],
    fed_funds_change=cycle_ranges["fed_funds_change"],
    monthly_returns=treas_10y_monthly,
)

display(treas_10y_cycles)

CycleStartEndMonthsCumulativeReturnCumulativeReturnPctAverageMonthlyReturnAverageMonthlyReturnPctAnnualizedReturnAnnualizedReturnPctVolatilityFedFundsChangeFedFundsChange_bpsFFR_AnnualizedChangeFFR_AnnualizedChange_bpsLabel
0Cycle 11989-12-311990-06-3060.013621.362000.002410.241010.027432.742550.066570.000000.000000.000000.00000Cycle 1, 1989-12-31 to 1990-06-30
1Cycle 21990-06-301993-03-31340.4568245.682260.011241.124280.1420214.201800.05346-0.05250-525.00000-0.01853-185.29412Cycle 2, 1990-06-30 to 1993-03-31
2Cycle 31993-03-311994-01-31110.088768.876100.007840.783560.097219.721080.044580.000000.000000.000000.00000Cycle 3, 1993-03-31 to 1994-01-31
3Cycle 41994-01-311995-06-30180.077967.796380.004390.438700.051325.132290.072870.03000300.000000.02000200.00000Cycle 4, 1994-01-31 to 1995-06-30
4Cycle 51995-06-301996-07-31140.047854.784670.003440.343870.040874.087380.04940-0.00750-75.00000-0.00643-64.28571Cycle 5, 1995-06-30 to 1996-07-31
5Cycle 61996-07-311997-02-2880.053015.301180.006580.658190.080568.056230.053650.000000.000000.000000.00000Cycle 6, 1996-07-31 to 1997-02-28
6Cycle 71997-02-281997-09-3080.064536.452980.007990.799040.098349.833980.062850.0025025.000000.0037537.50000Cycle 7, 1997-02-28 to 1997-09-30
7Cycle 81997-09-301998-08-31120.1442814.427970.011351.135160.1442814.427970.038910.000000.000000.000000.00000Cycle 8, 1997-09-30 to 1998-08-31
8Cycle 91998-08-311999-05-31100.030523.051620.003260.326300.036733.673030.08244-0.00750-75.00000-0.00900-90.00000Cycle 9, 1998-08-31 to 1999-05-31
9Cycle 101999-05-312000-11-30190.087448.744180.004490.449260.054375.437060.042500.01750175.000000.01105110.52632Cycle 10, 1999-05-31 to 2000-11-30
10Cycle 112000-11-302000-12-3120.049184.918390.024302.429720.3338633.385840.004580.000000.000000.000000.00000Cycle 11, 2000-11-30 to 2000-12-31
11Cycle 122000-12-312002-06-30190.1433114.330710.007210.721130.088268.826450.05901-0.04750-475.00000-0.03000-300.00000Cycle 12, 2000-12-31 to 2002-06-30
12Cycle 132002-06-302002-10-3150.099459.944960.019291.929050.2555125.551180.066820.000000.000000.000000.00000Cycle 13, 2002-06-30 to 2002-10-31
13Cycle 142002-10-312003-12-31150.021612.161300.001720.172180.017251.725340.08696-0.00750-75.00000-0.00600-60.00000Cycle 14, 2002-10-31 to 2003-12-31
14Cycle 152003-12-312004-05-316-0.00088-0.088310.000060.00600-0.00177-0.176540.076530.000000.000000.000000.00000Cycle 15, 2003-12-31 to 2004-05-31
15Cycle 162004-05-312006-12-31320.1026710.266700.003140.313820.037333.732930.044400.04250425.000000.01594159.37500Cycle 16, 2004-05-31 to 2006-12-31
16Cycle 172006-12-312007-08-3190.033003.300470.003710.371020.044254.424660.050980.000000.000000.000000.00000Cycle 17, 2006-12-31 to 2007-08-31
17Cycle 182007-08-312009-07-31240.1984319.843270.007920.791930.094739.472950.09432-0.05000-500.00000-0.02500-250.00000Cycle 18, 2007-08-31 to 2009-07-31
18Cycle 192009-07-312015-11-30770.3899538.994850.004430.443020.052655.265370.059450.000000.000000.000000.00000Cycle 19, 2009-07-31 to 2015-11-30
19Cycle 202015-11-302016-06-3080.067906.789980.008350.834700.1035610.355950.053170.0025025.000000.0037537.50000Cycle 20, 2015-11-30 to 2016-06-30
20Cycle 212016-06-302016-11-306-0.03175-3.17490-0.00513-0.51254-0.06249-6.248990.082420.000000.000000.000000.00000Cycle 21, 2016-06-30 to 2016-11-30
21Cycle 222016-11-302019-06-30320.059235.923380.001900.190020.021812.181420.049750.02000200.000000.0075075.00000Cycle 22, 2016-11-30 to 2019-06-30
22Cycle 232019-06-302019-07-3120.013061.305680.006530.652610.080948.094290.030240.000000.000000.000000.00000Cycle 23, 2019-06-30 to 2019-07-31
23Cycle 242019-07-312020-09-30150.1308313.082680.008370.837060.1033610.335910.06056-0.02250-225.00000-0.01800-180.00000Cycle 24, 2019-07-31 to 2020-09-30
24Cycle 252020-09-302022-02-2818-0.06522-6.52177-0.00366-0.36622-0.04397-4.396530.044300.000000.000000.000000.00000Cycle 25, 2020-09-30 to 2022-02-28
25Cycle 262022-02-282024-01-3124-0.09878-9.87840-0.00394-0.39369-0.05068-5.067600.098580.05250525.000000.02625262.50000Cycle 26, 2022-02-28 to 2024-01-31
26Cycle 272024-01-312024-08-3180.027482.748170.003570.357180.041504.150450.069570.000000.000000.000000.00000Cycle 27, 2024-01-31 to 2024-08-31
27Cycle 282024-08-312025-06-30110.032943.294080.003100.310100.035993.598870.06280-0.01000-100.00000-0.01091-109.09091Cycle 28, 2024-08-31 to 2025-06-30
28Cycle 292025-06-302025-08-3130.026702.669780.008880.887500.1111411.114450.044090.000000.000000.000000.00000Cycle 29, 2025-06-30 to 2025-08-31
29Cycle 302025-08-312026-01-3160.030813.080680.005100.510070.062566.256260.02992-0.00750-75.00000-0.01500-150.00000Cycle 30, 2025-08-31 to 2026-01-31

This gives us the following data points:

  • Cycle start date
  • Cycle end date
  • Number of months in the cycle
  • Cumulative return during the cycle (decimal and percent)
  • Average monthly return during the cycle (decimal and percent)
  • Annualized return during the cycle (decimal and percent)
  • Return volatility during the cycle
  • Cumulative change in FFR during the cycle (decimal and basis points)
  • Annualized change in FFR during the cycle (decimal and basis points)

From the above DataFrame, we can then plot the cumulative and annualized returns for each cycle in a bar chart. First, the cumulative returns along with the cumulative change in FFR:

plot_bar_returns_ffr_change(
    cycle_df=treas_10y_cycles,
    asset_label="TREAS_10Y",
    annualized_or_cumulative="Cumulative",
)

png

And then the annualized returns along with the annualized change in FFR:

plot_bar_returns_ffr_change(
    cycle_df=treas_10y_cycles,
    asset_label="TREAS_10Y",
    annualized_or_cumulative="Annualized",
)

png

For this dataset, we have cycles 11/12/13/14 exhibiting strong returns, which is consistent with the economic intuition that bonds should perform well during periods of economic weakness and rate cuts. We also see this outcome with cycle 18. It becomes a little more interesting during cycles 25 and 26, where the correlations of stocks and bond returns seemed to align, so we see negative bond returns there. Finally, cycles 28/29/30 also exhibit positive bond returns, which is consistent with our thesis that bonds should perform well during periods of economic weakness and rate cuts.

Finally, we can run an OLS regression to check fit:

df = treas_10y_cycles

#=== Don't modify below this line ===

# Run OLS regression with statsmodels
X = df["FFR_AnnualizedChange_bps"]
y = df["AnnualizedReturnPct"]
X = sm.add_constant(X)
model = sm.OLS(y, X).fit()
print(model.summary())
print(f"Intercept: {model.params[0]}, Slope: {model.params[1]}")  # Intercept and slope

# Calc X and Y values for regression line
X_vals = np.linspace(X.min(), X.max(), 100)
Y_vals = model.params[0] + model.params[1] * X_vals
                             OLS Regression Results                            
===============================================================================
Dep. Variable:     AnnualizedReturnPct   R-squared:                       0.050
Model:                             OLS   Adj. R-squared:                  0.016
Method:                  Least Squares   F-statistic:                     1.462
Date:                 Tue, 24 Feb 2026   Prob (F-statistic):              0.237
Time:                         14:11:34   Log-Likelihood:                -103.64
No. Observations:                   30   AIC:                             211.3
Df Residuals:                       28   BIC:                             214.1
Df Model:                            1                                         
Covariance Type:             nonrobust                                         
============================================================================================
                               coef    std err          t      P>|t|      [0.025      0.975]
--------------------------------------------------------------------------------------------
const                        6.7457      1.462      4.613      0.000       3.751       9.741
FFR_AnnualizedChange_bps    -0.0149      0.012     -1.209      0.237      -0.040       0.010
==============================================================================
Omnibus:                       19.978   Durbin-Watson:                   2.116
Prob(Omnibus):                  0.000   Jarque-Bera (JB):               29.185
Skew:                           1.582   Prob(JB):                     4.60e-07
Kurtosis:                       6.651   Cond. No.                         120.
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.
Intercept: 6.745718627069434, Slope: -0.01486255000436152

And then plot the regression line along with the values:

plot_scatter_regression_ffr_vs_returns(
    cycle_df=treas_10y_cycles,
    asset_label="TREAS_10Y",
    x_vals=X_vals,
    y_vals=Y_vals,
    intercept=model.params[0],
    slope=model.params[1],
)

png

The above plot is intriguing because of how well the OLS regression appears to fit the data. It certainly appears that during rate-cutting cycles, bonds are an asset that performs well.

High Yield Bonds #

Next, we’ll run a similar process for high yield bonds using LF98TRUU_Bloomberg US Corporate High Yield Total Return Index Value Unhedged USD.

First, we pull data with the following:

bb_clean_data(
    base_directory=DATA_DIR,
    fund_ticker_name="LF98TRUU_Bloomberg US Corporate High Yield Total Return Index Value Unhedged USD",
    source="Bloomberg",
    asset_class="Indices",
    excel_export=True,
    pickle_export=True,
    output_confirmation=False,
)

hy_bonds = load_data(
    base_directory=DATA_DIR,
    ticker="LF98TRUU_Bloomberg US Corporate High Yield Total Return Index Value Unhedged USD_Clean",
    source="Bloomberg", 
    asset_class="Indices",
    timeframe="Daily",
    file_format="pickle",
)

# Filter HY_BONDS to date range
hy_bonds = hy_bonds[(hy_bonds.index >= pd.to_datetime(start_date)) & (hy_bonds.index <= pd.to_datetime(end_date))]

# Drop everything except the "close" column
hy_bonds = hy_bonds[["Close"]]

# Resample to monthly frequency
hy_bonds_monthly = hy_bonds.resample("M").last()
hy_bonds_monthly["Monthly_Return"] = hy_bonds_monthly["Close"].pct_change()
hy_bonds_monthly = hy_bonds_monthly.dropna()

display(hy_bonds_monthly)

CloseMonthly_Return
Date
1990-01-31194.21000-0.02146
1990-02-28190.20000-0.02065
1990-03-31195.190000.02624
1990-04-30194.86000-0.00169
1990-05-31198.620000.01930
.........
2025-09-302876.850000.00816
2025-10-312881.380000.00157
2025-11-302898.070000.00579
2025-12-312914.490000.00567
2026-01-312929.320000.00509

433 rows × 2 columns

Next, we can plot the price history before calculating the cycle performance:

plot_timeseries(
    price_df=hy_bonds,
    plot_start_date=None,
    plot_end_date=None,
    plot_columns=["Close"],
    title="Bloomberg US Corporate High Yield Total Return Index Value Unhedged USD Daily Close Price",
    x_label="Date",
    x_format="Year",
    x_tick_spacing=2,
    x_tick_rotation=45,
    y_label="Price ($)",
    y_format="Decimal",
    y_format_decimal_places=0,
    y_tick_spacing=200,
    grid=True,
    legend=False,
    export_plot=False,
    plot_file_name=None,
)

png

Next, we will calculate the performance of high yield bonds based on the pre-defined Fed cycles:

hy_bonds_cycles = calc_fed_cycle_asset_performance(
    start_date=cycle_ranges["start_date"],
    end_date=cycle_ranges["end_date"],
    label=cycle_ranges["cycle_label"],
    fed_funds_change=cycle_ranges["fed_funds_change"],
    monthly_returns=hy_bonds_monthly,
)

display(hy_bonds_cycles)

CycleStartEndMonthsCumulativeReturnCumulativeReturnPctAverageMonthlyReturnAverageMonthlyReturnPctAnnualizedReturnAnnualizedReturnPctVolatilityFedFundsChangeFedFundsChange_bpsFFR_AnnualizedChangeFFR_AnnualizedChange_bpsLabel
0Cycle 11989-12-311990-06-3060.024942.494080.004320.431590.050505.050360.076250.000000.000000.000000.00000Cycle 1, 1989-12-31 to 1990-06-30
1Cycle 21990-06-301993-03-31340.6214962.148830.014801.479590.1860118.600690.10898-0.05250-525.00000-0.01853-185.29412Cycle 2, 1990-06-30 to 1993-03-31
2Cycle 31993-03-311994-01-31110.1426214.262350.012211.221290.1565615.655710.022270.000000.000000.000000.00000Cycle 3, 1993-03-31 to 1994-01-31
3Cycle 41994-01-311995-06-30180.1125411.254220.006070.607010.073697.368690.056910.03000300.000000.02000200.00000Cycle 4, 1994-01-31 to 1995-06-30
4Cycle 51995-06-301996-07-31140.1089210.892350.007430.742620.092679.266510.01906-0.00750-75.00000-0.00643-64.28571Cycle 5, 1995-06-30 to 1996-07-31
5Cycle 61996-07-311997-02-2880.1047910.478890.012551.255480.1612316.123190.023660.000000.000000.000000.00000Cycle 6, 1996-07-31 to 1997-02-28
6Cycle 71997-02-281997-09-3080.095579.557030.011561.155810.1467314.672790.047920.0025025.000000.0037537.50000Cycle 7, 1997-02-28 to 1997-09-30
7Cycle 81997-09-301998-08-31120.032193.219380.002820.281690.032193.219380.066310.000000.000000.000000.00000Cycle 8, 1997-09-30 to 1998-08-31
8Cycle 91998-08-311999-05-3110-0.00728-0.72813-0.00042-0.04217-0.00873-0.873120.09026-0.00750-75.00000-0.00900-90.00000Cycle 9, 1998-08-31 to 1999-05-31
9Cycle 101999-05-312000-11-3019-0.08916-8.91594-0.00480-0.47963-0.05728-5.727580.051720.01750175.000000.01105110.52632Cycle 10, 1999-05-31 to 2000-11-30
10Cycle 112000-11-302000-12-312-0.02105-2.10510-0.01014-1.01430-0.11984-11.984270.144320.000000.000000.000000.00000Cycle 11, 2000-11-30 to 2000-12-31
11Cycle 122000-12-312002-06-30190.021172.117010.001670.166930.013321.331890.11914-0.04750-475.00000-0.03000-300.00000Cycle 12, 2000-12-31 to 2002-06-30
12Cycle 132002-06-302002-10-315-0.10874-10.87431-0.02215-2.21511-0.24141-24.141020.133640.000000.000000.000000.00000Cycle 13, 2002-06-30 to 2002-10-31
13Cycle 142002-10-312003-12-31150.3766237.661820.021722.172300.2913729.137030.07004-0.00750-75.00000-0.00600-60.00000Cycle 14, 2002-10-31 to 2003-12-31
14Cycle 152003-12-312004-05-3160.021882.187820.003710.371150.044244.423500.053220.000000.000000.000000.00000Cycle 15, 2003-12-31 to 2004-05-31
15Cycle 162004-05-312006-12-31320.2562525.624590.007220.722110.089318.931380.040560.04250425.000000.01594159.37500Cycle 16, 2004-05-31 to 2006-12-31
16Cycle 172006-12-312007-08-3190.016831.683500.001990.199140.022512.250940.059920.000000.000000.000000.00000Cycle 17, 2006-12-31 to 2007-08-31
17Cycle 182007-08-312009-07-31240.049084.908050.003710.370920.024252.424630.20404-0.05000-500.00000-0.02500-250.00000Cycle 18, 2007-08-31 to 2009-07-31
18Cycle 192009-07-312015-11-30770.8314683.145990.008080.808200.098899.889310.068670.000000.000000.000000.00000Cycle 19, 2009-07-31 to 2015-11-30
19Cycle 202015-11-302016-06-3080.039483.947810.005150.515080.059805.979770.091070.0025025.000000.0037537.50000Cycle 20, 2015-11-30 to 2016-06-30
20Cycle 212016-06-302016-11-3060.064266.426280.010491.049000.1326613.265540.040250.000000.000000.000000.00000Cycle 21, 2016-06-30 to 2016-11-30
21Cycle 222016-11-302019-06-30320.1730617.305680.005080.507590.061686.168250.043470.02000200.000000.0075075.00000Cycle 22, 2016-11-30 to 2019-06-30
22Cycle 232019-06-302019-07-3120.028562.855720.014211.421440.1840518.405200.042030.000000.000000.000000.00000Cycle 23, 2019-06-30 to 2019-07-31
23Cycle 242019-07-312020-09-30150.046314.631400.003720.372440.036883.688270.13113-0.02250-225.00000-0.01800-180.00000Cycle 24, 2019-07-31 to 2020-09-30
24Cycle 252020-09-302022-02-28180.067756.774960.003740.374440.044674.467120.049520.000000.000000.000000.00000Cycle 25, 2020-09-30 to 2022-02-28
25Cycle 262022-02-282024-01-31240.035803.580420.001860.186050.017741.774470.099160.05250525.000000.02625262.50000Cycle 26, 2022-02-28 to 2024-01-31
26Cycle 272024-01-312024-08-3180.062856.284800.007690.768660.095749.573810.032550.000000.000000.000000.00000Cycle 27, 2024-01-31 to 2024-08-31
27Cycle 282024-08-312025-06-30110.081828.181580.007220.722410.089588.957760.03617-0.01000-100.00000-0.01091-109.09091Cycle 28, 2024-08-31 to 2025-06-30
28Cycle 292025-06-302025-08-3130.035773.576730.011801.179910.1509315.092970.024140.000000.000000.000000.00000Cycle 29, 2025-06-30 to 2025-08-31
29Cycle 302025-08-312026-01-3160.039373.937410.006460.646270.080308.029850.01260-0.00750-75.00000-0.01500-150.00000Cycle 30, 2025-08-31 to 2026-01-31

This gives us the following data points:

  • Cycle start date
  • Cycle end date
  • Number of months in the cycle
  • Cumulative return during the cycle (decimal and percent)
  • Average monthly return during the cycle (decimal and percent)
  • Annualized return during the cycle (decimal and percent)
  • Return volatility during the cycle
  • Cumulative change in FFR during the cycle (decimal and basis points)
  • Annualized change in FFR during the cycle (decimal and basis points)

From the above DataFrame, we can then plot the cumulative and annualized returns for each cycle in a bar chart. First, the cumulative returns along with the cumulative change in FFR:

plot_bar_returns_ffr_change(
    cycle_df=hy_bonds_cycles,
    asset_label="HY_BONDS",
    annualized_or_cumulative="Cumulative",
)

png

And then the annualized returns along with the annualized change in FFR:

plot_bar_returns_ffr_change(
    cycle_df=hy_bonds_cycles,
    asset_label="HY_BONDS",
    annualized_or_cumulative="Annualized",
)

png

Finally, we can run an OLS regression to check fit:

df = hy_bonds_cycles

#=== Don't modify below this line ===

# Run OLS regression with statsmodels
X = df["FFR_AnnualizedChange_bps"]
y = df["AnnualizedReturnPct"]
X = sm.add_constant(X)
model = sm.OLS(y, X).fit()
print(model.summary())
print(f"Intercept: {model.params[0]}, Slope: {model.params[1]}")  # Intercept and slope

# Calc X and Y values for regression line
X_vals = np.linspace(X.min(), X.max(), 100)
Y_vals = model.params[0] + model.params[1] * X_vals
                             OLS Regression Results                            
===============================================================================
Dep. Variable:     AnnualizedReturnPct   R-squared:                       0.004
Model:                             OLS   Adj. R-squared:                 -0.031
Method:                  Least Squares   F-statistic:                    0.1171
Date:                 Tue, 24 Feb 2026   Prob (F-statistic):              0.735
Time:                         14:11:36   Log-Likelihood:                -110.57
No. Observations:                   30   AIC:                             225.1
Df Residuals:                       28   BIC:                             227.9
Df Model:                            1                                         
Covariance Type:             nonrobust                                         
============================================================================================
                               coef    std err          t      P>|t|      [0.025      0.975]
--------------------------------------------------------------------------------------------
const                        6.6113      1.842      3.590      0.001       2.839      10.384
FFR_AnnualizedChange_bps    -0.0053      0.015     -0.342      0.735      -0.037       0.026
==============================================================================
Omnibus:                        9.087   Durbin-Watson:                   1.936
Prob(Omnibus):                  0.011   Jarque-Bera (JB):                9.047
Skew:                          -0.790   Prob(JB):                       0.0109
Kurtosis:                       5.178   Cond. No.                         120.
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.
Intercept: 6.611340605920211, Slope: -0.005299161729553994

And then plot the regression line along with the values:

plot_scatter_regression_ffr_vs_returns(
    cycle_df=treas_10y_cycles,
    asset_label="TREAS_10Y",
    x_vals=X_vals,
    y_vals=Y_vals,
    intercept=model.params[0],
    slope=model.params[1],
)

png

Gold #

Finally, we’ll run a similar process for gold using XAU_Gold USD Spot.

First, we pull data with the following:

bb_clean_data(
    base_directory=DATA_DIR,
    fund_ticker_name="XAU_Gold USD Spot",
    source="Bloomberg",
    asset_class="Commodities",
    excel_export=True,
    pickle_export=True,
    output_confirmation=False,
)

gold = load_data(
    base_directory=DATA_DIR,
    ticker="XAU_Gold USD Spot_Clean",
    source="Bloomberg", 
    asset_class="Commodities",
    timeframe="Daily",
    file_format="pickle",
)

# Filter GOLD to date range
gold = gold[(gold.index >= pd.to_datetime(start_date)) & (gold.index <= pd.to_datetime(end_date))]

# Drop everything except the "close" column
gold = gold[["Close"]]

# Resample to monthly frequency
gold_monthly = gold.resample("M").last()
gold_monthly["Monthly_Return"] = gold_monthly["Close"].pct_change()
gold_monthly = gold_monthly.dropna()

display(gold_monthly)

CloseMonthly_Return
Date
1990-01-31415.050000.03439
1990-02-28407.70000-0.01771
1990-03-31368.50000-0.09615
1990-04-30367.75000-0.00204
1990-05-31363.05000-0.01278
.........
2025-09-303858.960000.11920
2025-10-314002.920000.03731
2025-11-304239.430000.05908
2025-12-314319.370000.01886
2026-01-314894.230000.13309

433 rows × 2 columns

Next, we can plot the price history before calculating the cycle performance:

plot_timeseries(
    price_df=gold,
    plot_start_date=None,
    plot_end_date=None,
    plot_columns=["Close"],
    title="XAU Gold USD Spot Daily Close Price",
    x_label="Date",
    x_format="Year",
    x_tick_spacing=2,
    x_tick_rotation=45,
    y_label="Price ($)",
    y_format="Decimal",
    y_format_decimal_places=0,
    y_tick_spacing=400,
    grid=True,
    legend=False,
    export_plot=False,
    plot_file_name=None,
)

png

Next, we will calculate the performance of gold based on the pre-defined Fed cycles:

gold_cycles = calc_fed_cycle_asset_performance(
    start_date=cycle_ranges["start_date"],
    end_date=cycle_ranges["end_date"],
    label=cycle_ranges["cycle_label"],
    fed_funds_change=cycle_ranges["fed_funds_change"],
    monthly_returns=gold_monthly,
)

display(gold_cycles)

CycleStartEndMonthsCumulativeReturnCumulativeReturnPctAverageMonthlyReturnAverageMonthlyReturnPctAnnualizedReturnAnnualizedReturnPctVolatilityFedFundsChangeFedFundsChange_bpsFFR_AnnualizedChangeFFR_AnnualizedChange_bpsLabel
0Cycle 11989-12-311990-06-306-0.12224-12.22430-0.02069-2.06945-0.22954-22.954260.148850.000000.000000.000000.00000Cycle 1, 1989-12-31 to 1990-06-30
1Cycle 21990-06-301993-03-3134-0.06624-6.62443-0.00158-0.15839-0.02390-2.390050.10292-0.05250-525.00000-0.01853-185.29412Cycle 2, 1990-06-30 to 1993-03-31
2Cycle 31993-03-311994-01-31110.1590315.902880.014621.462380.1746817.468380.170750.000000.000000.000000.00000Cycle 3, 1993-03-31 to 1994-01-31
3Cycle 41994-01-311995-06-3018-0.01561-1.56130-0.00070-0.06966-0.01044-1.043590.067170.03000300.000000.02000200.00000Cycle 4, 1994-01-31 to 1995-06-30
4Cycle 51995-06-301996-07-31140.007160.715590.000660.065520.006130.613050.06186-0.00750-75.00000-0.00643-64.28571Cycle 5, 1995-06-30 to 1996-07-31
5Cycle 61996-07-311997-02-288-0.04468-4.46839-0.00519-0.51936-0.06627-6.627150.117370.000000.000000.000000.00000Cycle 6, 1996-07-31 to 1997-02-28
6Cycle 71997-02-281997-09-308-0.02875-2.87498-0.00313-0.31292-0.04281-4.281330.118860.0025025.000000.0037537.50000Cycle 7, 1997-02-28 to 1997-09-30
7Cycle 81997-09-301998-08-3112-0.14993-14.99306-0.01285-1.28489-0.14993-14.993060.124330.000000.000000.000000.00000Cycle 8, 1997-09-30 to 1998-08-31
8Cycle 91998-08-311999-05-3110-0.05621-5.62053-0.00517-0.51718-0.06706-6.706140.12709-0.00750-75.00000-0.00900-90.00000Cycle 9, 1998-08-31 to 1999-05-31
9Cycle 101999-05-312000-11-3019-0.05619-5.61857-0.00193-0.19269-0.03586-3.586270.173330.01750175.000000.01105110.52632Cycle 10, 1999-05-31 to 2000-11-30
10Cycle 112000-11-302000-12-3120.026782.677730.013321.332210.1718117.181090.032660.000000.000000.000000.00000Cycle 11, 2000-11-30 to 2000-12-31
11Cycle 122000-12-312002-06-30190.1626916.269180.008440.844060.099889.988190.11022-0.04750-475.00000-0.03000-300.00000Cycle 12, 2000-12-31 to 2002-06-30
12Cycle 132002-06-302002-10-315-0.02695-2.69484-0.00496-0.49607-0.06346-6.346050.121040.000000.000000.000000.00000Cycle 13, 2002-06-30 to 2002-10-31
13Cycle 142002-10-312003-12-31150.2840428.403650.017721.771920.2214122.141120.15439-0.00750-75.00000-0.00600-60.00000Cycle 14, 2002-10-31 to 2003-12-31
14Cycle 152003-12-312004-05-316-0.00653-0.653020.000440.04419-0.01302-1.301780.208680.000000.000000.000000.00000Cycle 15, 2003-12-31 to 2004-05-31
15Cycle 162004-05-312006-12-31320.6462864.628310.016531.653260.2055620.556100.145680.04250425.000000.01594159.37500Cycle 16, 2004-05-31 to 2006-12-31
16Cycle 172006-12-312007-08-3190.039043.904320.004470.446600.052395.239350.073740.000000.000000.000000.00000Cycle 17, 2006-12-31 to 2007-08-31
17Cycle 182007-08-312009-07-31240.4361043.609810.017661.766410.1983719.837310.24658-0.05000-500.00000-0.02500-250.00000Cycle 18, 2007-08-31 to 2009-07-31
18Cycle 192009-07-312015-11-30770.1492414.923910.003170.317420.021912.191460.182530.000000.000000.000000.00000Cycle 19, 2009-07-31 to 2015-11-30
19Cycle 202015-11-302016-06-3080.1574215.741920.020272.026790.2451924.519110.225310.0025025.000000.0037537.50000Cycle 20, 2015-11-30 to 2016-06-30
20Cycle 212016-06-302016-11-306-0.03466-3.46575-0.00449-0.44887-0.06811-6.811390.199400.000000.000000.000000.00000Cycle 21, 2016-06-30 to 2016-11-30
21Cycle 222016-11-302019-06-30320.1036210.361650.003550.355190.037663.766420.107580.02000200.000000.0075075.00000Cycle 22, 2016-11-30 to 2019-06-30
22Cycle 232019-06-302019-07-3120.082888.287500.041324.131800.6123961.238990.187710.000000.000000.000000.00000Cycle 23, 2019-06-30 to 2019-07-31
23Cycle 242019-07-312020-09-30150.3378933.788800.020432.043360.2622226.222220.14899-0.02250-225.00000-0.01800-180.00000Cycle 24, 2019-07-31 to 2020-09-30
24Cycle 252020-09-302022-02-2818-0.02989-2.98862-0.00076-0.07561-0.02002-2.002470.153880.000000.000000.000000.00000Cycle 25, 2020-09-30 to 2022-02-28
25Cycle 262022-02-282024-01-31240.1348513.485090.006050.605160.065296.529380.139960.05250525.000000.02625262.50000Cycle 26, 2022-02-28 to 2024-01-31
26Cycle 272024-01-312024-08-3180.2134821.348240.024942.493530.3367533.675020.113990.000000.000000.000000.00000Cycle 27, 2024-01-31 to 2024-08-31
27Cycle 282024-08-312025-06-30110.3495434.954240.028242.824190.3868338.682500.12923-0.01000-100.00000-0.01091-109.09091Cycle 28, 2024-08-31 to 2025-06-30
28Cycle 292025-06-302025-08-3130.048254.824810.016091.608500.2074120.741430.096890.000000.000000.000000.00000Cycle 29, 2025-06-30 to 2025-08-31
29Cycle 302025-08-312026-01-3160.4876448.763960.069266.926181.21307121.307140.16013-0.00750-75.00000-0.01500-150.00000Cycle 30, 2025-08-31 to 2026-01-31

This gives us the following data points:

  • Cycle start date
  • Cycle end date
  • Number of months in the cycle
  • Cumulative return during the cycle (decimal and percent)
  • Average monthly return during the cycle (decimal and percent)
  • Annualized return during the cycle (decimal and percent)
  • Return volatility during the cycle
  • Cumulative change in FFR during the cycle (decimal and basis points)
  • Annualized change in FFR during the cycle (decimal and basis points)

From the above DataFrame, we can then plot the cumulative and annualized returns for each cycle in a bar chart. First, the cumulative returns along with the cumulative change in FFR:

plot_bar_returns_ffr_change(
    cycle_df=gold_cycles,
    asset_label="GOLD",
    annualized_or_cumulative="Cumulative",
)

png

And then the annualized returns along with the annualized change in FFR:

plot_bar_returns_ffr_change(
    cycle_df=gold_cycles,
    asset_label="GOLD",
    annualized_or_cumulative="Annualized",
)

png

We see strong returns for gold across several different Fed cycles, so it is difficult to draw any kind of initial conclusion based on the bar charts.

Finally, we can run an OLS regression to check fit:

df = gold_cycles

#=== Don't modify below this line ===

# Run OLS regression with statsmodels
X = df["FFR_AnnualizedChange_bps"]
y = df["AnnualizedReturnPct"]
X = sm.add_constant(X)
model = sm.OLS(y, X).fit()
print(model.summary())
print(f"Intercept: {model.params[0]}, Slope: {model.params[1]}")  # Intercept and slope

# Calc X and Y values for regression line
X_vals = np.linspace(X.min(), X.max(), 100)
Y_vals = model.params[0] + model.params[1] * X_vals
                             OLS Regression Results                            
===============================================================================
Dep. Variable:     AnnualizedReturnPct   R-squared:                       0.064
Model:                             OLS   Adj. R-squared:                  0.030
Method:                  Least Squares   F-statistic:                     1.900
Date:                 Tue, 24 Feb 2026   Prob (F-statistic):              0.179
Time:                         14:11:39   Log-Likelihood:                -140.03
No. Observations:                   30   AIC:                             284.1
Df Residuals:                       28   BIC:                             286.9
Df Model:                            1                                         
Covariance Type:             nonrobust                                         
============================================================================================
                               coef    std err          t      P>|t|      [0.025      0.975]
--------------------------------------------------------------------------------------------
const                       11.4670      4.917      2.332      0.027       1.396      21.539
FFR_AnnualizedChange_bps    -0.0570      0.041     -1.378      0.179      -0.142       0.028
==============================================================================
Omnibus:                       28.991   Durbin-Watson:                   1.081
Prob(Omnibus):                  0.000   Jarque-Bera (JB):               62.894
Skew:                           2.093   Prob(JB):                     2.20e-14
Kurtosis:                       8.727   Cond. No.                         120.
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.
Intercept: 11.467013480569243, Slope: -0.056974234027166226

And then plot the regression line along with the values:

plot_scatter_regression_ffr_vs_returns(
    cycle_df=gold_cycles,
    asset_label="GOLD",
    x_vals=X_vals,
    y_vals=Y_vals,
    intercept=model.params[0],
    slope=model.params[1],
)

png

It’s difficult to draw any strong conclusions with the above plot. Gold has traditionally been considered a hedge for inflation, and while one of the Fed’s mandates is to manage inflation, there may not be a conclusion to draw in relationship to the historical returns that gold has exhibited.

Asset Allocation #

With the above analysis (somewhat) complete, let’s look at the optimal allocation for a portfolio based on the data and the hypythetical historical results.

We have to be careful with our criteria for when to hold stocks, bonds, or gold, as hindsight bias is certainly possible. So, without overanalyzing the results, let’s assume that we hold stocks as the default position during tightening cycles, and then hold bonds during easing cycles when the Fed starts cutting rates, and then resume holding stocks when the Fed stops cutting rates. If there is not any change in FFR, then we still hold stocks.

We can then combine the return series based on the above with the following:

# Shift the "cycle_filled" column down by one row to create a new column called "cycle_invested" that represents the cycle label that an investor would be invested in for each month (i.e. the cycle label from the previous month)

fedfunds_grouped_cycles['cycle_invested'] = fedfunds_grouped_cycles['cycle_filled'].shift(1)

display(fedfunds_grouped_cycles)

start_datefed_funds_startend_datefed_funds_endfed_funds_changecyclecycle_filledgroupcycle_invested
01989-12-310.082501990-01-310.082500.00000NeutralModified Tightening1None
11990-01-310.082501990-02-280.082500.00000NeutralModified Tightening1Modified Tightening
21990-02-280.082501990-03-310.082500.00000NeutralModified Tightening1Modified Tightening
31990-03-310.082501990-04-300.082500.00000NeutralModified Tightening1Modified Tightening
41990-04-300.082501990-05-310.082500.00000NeutralModified Tightening1Modified Tightening
..............................
4292025-09-300.042502025-10-310.04000-0.00250EasingEasing30Easing
4302025-10-310.040002025-11-300.040000.00000NeutralEasing30Easing
4312025-11-300.040002025-12-310.03750-0.00250EasingEasing30Easing
4322025-12-310.037502026-01-310.037500.00000NeutralEasing30Easing
4332026-01-310.03750NaTNaNNaNNeutralEasing30Easing

434 rows × 9 columns

# Reset index to merge on date
stocks_merged = pd.merge_asof(
    spxt_monthly.reset_index(),
    fedfunds_grouped_cycles[['start_date', 'end_date', 'cycle_invested', 'group']],
    left_on='Date',
    right_on='start_date',
    direction='backward'
)

# Drop rows where the date falls outside the cycle's end_date
stocks_merged = stocks_merged[stocks_merged['Date'] <= stocks_merged['end_date']]

display(stocks_merged)

DateCloseMonthly_Returnstart_dateend_datecycle_investedgroup
01990-01-31353.94000-0.067131990-01-311990-02-28Modified Tightening1
11990-02-28358.500000.012881990-02-281990-03-31Modified Tightening1
21990-03-313680.026501990-03-311990-04-30Modified Tightening1
31990-04-30358.81000-0.024971990-04-301990-05-31Modified Tightening1
41990-05-31393.800000.097521990-05-311990-06-30Modified Tightening1
........................
4272025-08-3114304.680000.020272025-08-312025-09-30Modified Tightening30
4282025-09-3014826.800000.036502025-09-302025-10-31Easing30
4292025-10-3115173.950000.023412025-10-312025-11-30Easing30
4302025-11-3015211.140000.002452025-11-302025-12-31Easing30
4312025-12-3115220.450000.000612025-12-312026-01-31Easing30

432 rows × 7 columns

# Reset index to merge on date
bonds_merged = pd.merge_asof(
    treas_10y_monthly.reset_index(),
    fedfunds_grouped_cycles[['start_date', 'end_date', 'cycle_invested', 'group']],
    left_on='Date',
    right_on='start_date',
    direction='backward'
)

# Drop rows where the date falls outside the cycle's end_date
bonds_merged = bonds_merged[bonds_merged['Date'] <= bonds_merged['end_date']]

display(bonds_merged)

DateCloseMonthly_Returnstart_dateend_datecycle_investedgroup
01990-01-3198.01300-0.019871990-01-311990-02-28Modified Tightening1
11990-02-2897.99000-0.000231990-02-281990-03-31Modified Tightening1
21990-03-3197.98900-0.000011990-03-311990-04-30Modified Tightening1
31990-04-3096.60600-0.014111990-04-301990-05-31Modified Tightening1
41990-05-3199.647000.031481990-05-311990-06-30Modified Tightening1
........................
4272025-08-31641.258000.016682025-08-312025-09-30Modified Tightening30
4282025-09-30645.584000.006752025-09-302025-10-31Easing30
4292025-10-31650.005000.006852025-10-312025-11-30Easing30
4302025-11-30656.636000.010202025-11-302025-12-31Easing30
4312025-12-31652.28800-0.006622025-12-312026-01-31Easing30

432 rows × 7 columns

# Select the appropriate return based on cycle
stocks_merged['strategy_return'] = stocks_merged.apply(
    lambda row: row['Monthly_Return'] if row['cycle_invested'] in ['Tightening', 'Modified Tightening'] else None,
    axis=1
)

bonds_merged['strategy_return'] = bonds_merged.apply(
    lambda row: row['Monthly_Return'] if row['cycle_invested'] == 'Easing' else None,
    axis=1
)

# Combine
strategy = pd.concat([stocks_merged, bonds_merged]).dropna(subset=['strategy_return'])
strategy = strategy.sort_values('Date')

display(strategy.head(20))

DateCloseMonthly_Returnstart_dateend_datecycle_investedgroupstrategy_return
01990-01-31353.94000-0.067131990-01-311990-02-28Modified Tightening1-0.06713
11990-02-28358.500000.012881990-02-281990-03-31Modified Tightening10.01288
21990-03-313680.026501990-03-311990-04-30Modified Tightening10.02650
31990-04-30358.81000-0.024971990-04-301990-05-31Modified Tightening1-0.02497
41990-05-31393.800000.097521990-05-311990-06-30Modified Tightening10.09752
51990-06-30391.14000-0.006751990-06-301990-07-31Modified Tightening2-0.00675
61990-07-31102.845000.014631990-07-311990-08-31Easing20.01463
71990-08-31100.78200-0.020061990-08-311990-09-30Easing2-0.02006
81990-09-30101.740000.009511990-09-301990-10-31Easing20.00951
91990-10-31103.652000.018791990-10-311990-11-30Easing20.01879
101990-11-30106.431000.026811990-11-301990-12-31Easing20.02681
111990-12-31108.249000.017081990-12-311991-01-31Easing20.01708
121991-01-31109.525000.011791991-01-311991-02-28Easing20.01179
131991-02-28110.104000.005291991-02-281991-03-31Easing20.00529
141991-03-31110.434000.003001991-03-311991-04-30Easing20.00300
151991-04-30111.598000.010541991-04-301991-05-31Easing20.01054
161991-05-31112.078000.004301991-05-311991-06-30Easing20.00430
171991-06-30111.50200-0.005141991-06-301991-07-31Easing2-0.00514
181991-07-31113.052000.013901991-07-311991-08-31Easing20.01390
191991-08-31116.065000.026651991-08-311991-09-30Easing20.02665
# Calculate cumulative returns and drawdown for spxt
spxt_monthly['Cumulative_Return'] = (1 + spxt_monthly['Monthly_Return']).cumprod() - 1
spxt_monthly['Cumulative_Return_Plus_One'] = 1 + spxt_monthly['Cumulative_Return']
spxt_monthly['Rolling_Max'] = spxt_monthly['Cumulative_Return_Plus_One'].cummax()
spxt_monthly['Drawdown'] = spxt_monthly['Cumulative_Return_Plus_One'] / spxt_monthly['Rolling_Max'] - 1
spxt_monthly.drop(columns=['Cumulative_Return_Plus_One', 'Rolling_Max'], inplace=True)

# Calculate cumulative returns and drawdown for treas_10y
treas_10y_monthly['Cumulative_Return'] = (1 + treas_10y_monthly['Monthly_Return']).cumprod() - 1
treas_10y_monthly['Cumulative_Return_Plus_One'] = 1 + treas_10y_monthly['Cumulative_Return']
treas_10y_monthly['Rolling_Max'] = treas_10y_monthly['Cumulative_Return_Plus_One'].cummax()
treas_10y_monthly['Drawdown'] = treas_10y_monthly['Cumulative_Return_Plus_One'] / treas_10y_monthly['Rolling_Max'] - 1
treas_10y_monthly.drop(columns=['Cumulative_Return_Plus_One', 'Rolling_Max'], inplace=True)

# Convert to DataFrame
portfolio_monthly = strategy[['Date', 'strategy_return']].copy().set_index('Date')
portfolio_monthly = portfolio_monthly.rename(columns={'strategy_return': 'Portfolio_Monthly_Return'})

# Calculate cumulative returns and drawdown for the portfolio
portfolio_monthly['Portfolio_Cumulative_Return'] = (1 + portfolio_monthly['Portfolio_Monthly_Return']).cumprod() - 1
portfolio_monthly['Portfolio_Cumulative_Return_Plus_One'] = 1 + portfolio_monthly['Portfolio_Cumulative_Return']
portfolio_monthly['Portfolio_Rolling_Max'] = portfolio_monthly['Portfolio_Cumulative_Return_Plus_One'].cummax()
portfolio_monthly['Portfolio_Drawdown'] = portfolio_monthly['Portfolio_Cumulative_Return_Plus_One'] / portfolio_monthly['Portfolio_Rolling_Max'] - 1
portfolio_monthly.drop(columns=['Portfolio_Cumulative_Return_Plus_One', 'Portfolio_Rolling_Max'], inplace=True)

# Merge "spxt_monthly" and "treas_10y_monthly" into "portfolio_monthly" to compare cumulative returns
portfolio_monthly = portfolio_monthly.join(
    spxt_monthly['Monthly_Return'].rename('SPXT_Monthly_Return'),
    how='left'
).join(
    spxt_monthly['Cumulative_Return'].rename('SPXT_Cumulative_Return'),
    how='left'
).join(
    spxt_monthly['Drawdown'].rename('SPXT_Drawdown'),
    how='left'
).join(
    treas_10y_monthly['Monthly_Return'].rename('10Y_Monthly_Return'),
    how='left'
).join(
    treas_10y_monthly['Cumulative_Return'].rename('10Y_Cumulative_Return'),
    how='left'
).join(
    treas_10y_monthly['Drawdown'].rename('10Y_Drawdown'),
    how='left'
)
display(portfolio_monthly)

Portfolio_Monthly_ReturnPortfolio_Cumulative_ReturnPortfolio_DrawdownSPXT_Monthly_ReturnSPXT_Cumulative_ReturnSPXT_Drawdown10Y_Monthly_Return10Y_Cumulative_Return10Y_Drawdown
Date
1990-01-31-0.06713-0.067130.00000-0.06713-0.067130.00000-0.01987-0.019870.00000
1990-02-280.01288-0.055110.000000.01288-0.055110.00000-0.00023-0.02010-0.00023
1990-03-310.02650-0.030070.000000.02650-0.030070.00000-0.00001-0.02011-0.00024
1990-04-30-0.02497-0.05429-0.02497-0.02497-0.05429-0.02497-0.01411-0.03394-0.01436
1990-05-310.097520.037930.000000.097520.037930.000000.03148-0.003530.00000
..............................
2025-08-310.0202742.285060.000000.0202736.702430.000000.016685.41258-0.11316
2025-09-300.0067542.577060.000000.0365038.078570.000000.006755.45584-0.10717
2025-10-310.0068542.875480.000000.0234138.993540.000000.006855.50005-0.10106
2025-11-300.0102043.323080.000000.0024539.091560.000000.010205.56636-0.09189
2025-12-31-0.0066243.02959-0.006620.0006139.116100.00000-0.006625.52288-0.09790

432 rows × 9 columns

Next, we’ll look at performance for the assets and portfolio.

Performance Statistics #

We can then plot the monthly returns:

plot_timeseries(
    price_df=portfolio_monthly,
    plot_start_date=start_date,
    plot_end_date=end_date,
    plot_columns=["Portfolio_Monthly_Return", "SPXT_Monthly_Return", "10Y_Monthly_Return"],
    title="Portfolio, SPXT, and 10Y Monthly Returns",
    x_label="Date",
    x_format="Year",
    x_tick_spacing=2,
    x_tick_rotation=45,
    y_label="Return",
    y_format="Decimal",
    y_format_decimal_places=2,
    y_tick_spacing=0.02,
    grid=True,
    legend=True,
    export_plot=False,
    plot_file_name=None,
)

png

And cumulative returns:

plot_timeseries(
    price_df=portfolio_monthly,
    plot_start_date=start_date,
    plot_end_date=end_date,
    plot_columns=["Portfolio_Cumulative_Return", "SPXT_Cumulative_Return", "10Y_Cumulative_Return"],
    title="Portfolio, SPXT, and 10Y Cumulative Returns",
    x_label="Date",
    x_format="Year",
    x_tick_spacing=2,
    x_tick_rotation=45,
    y_label="Cumulative Return",
    y_format="Decimal",
    y_format_decimal_places=0,
    y_tick_spacing=3,
    grid=True,
    legend=True,
    export_plot=False,
    plot_file_name=None,
)

png

And drawdowns:

plot_timeseries(
    price_df=portfolio_monthly,
    plot_start_date=start_date,
    plot_end_date=end_date,
    plot_columns=["Portfolio_Drawdown", "SPXT_Drawdown", "10Y_Drawdown"],
    title="Portfolio, SPXT, and 10Y Drawdowns",
    x_label="Date",
    x_format="Year",
    x_tick_spacing=2,
    x_tick_rotation=45,
    y_label="Drawdown",
    y_format="Decimal",
    y_format_decimal_places=2,
    y_tick_spacing=0.05,
    grid=True,
    legend=True,
    export_plot=False,
    plot_file_name=None,
)

png

Finally, we can run the summary statistics on the strategy portfolio, SPY, and TLT with the following code:

port_sum_stats = summary_stats(
    fund_list=["Portfolio", "SPXT", "10Y"],
    df=portfolio_monthly[["Portfolio_Monthly_Return"]],
    period="Monthly",
    use_calendar_days=False,
    excel_export=False,
    pickle_export=False,
    output_confirmation=False,
)

spy_sum_stats = summary_stats(
    fund_list=["Portfolio", "SPXT", "10Y"],
    df=portfolio_monthly[["SPXT_Monthly_Return"]],
    period="Monthly",
    use_calendar_days=False,
    excel_export=False,
    pickle_export=False,
    output_confirmation=False,
)

tlt_sum_stats = summary_stats(
    fund_list=["Portfolio", "SPXT", "10Y"],
    df=portfolio_monthly[["10Y_Monthly_Return"]],
    period="Monthly",
    use_calendar_days=False,
    excel_export=False,
    pickle_export=False,
    output_confirmation=False,
)

sum_stats = pd.concat([port_sum_stats, spy_sum_stats, tlt_sum_stats])
sum_stats

Annual Mean Return (Arithmetic)Annualized VolatilityAnnualized Sharpe RatioCAGR (Geometric)Monthly Max ReturnMonthly Max Return (Date)Monthly Min ReturnMonthly Min Return (Date)Max DrawdownPeakTroughRecovery DateDays to RecoveryMAR Ratio
Portfolio_Monthly_Return0.112670.118970.947010.110860.109462020-11-30-0.144581998-08-31-0.238672021-12-312022-09-302023-12-31457.000000.46449
SPXT_Monthly_Return0.113920.147350.773110.107990.128192020-04-30-0.167952008-10-31-0.509492007-10-312009-02-282012-03-311127.000000.21196
10Y_Monthly_Return0.054200.063310.856060.053470.081692008-11-30-0.055582003-07-31-0.228652020-07-312023-10-31NaTNaN0.23386

Based on the above, our strategy portfolio outperforms both stocks and bonds, with lower drawdowns.

Conclusions #

This was a interesting exercise to evaluate the performance of different asset classes during Fed tightening and easing cycles. The results are not particularly surprising, but it is interesting to see the data and plots to confirm the economic intuition that stocks perform well during tightening cycles (economic strength) and bonds perform well during easing cycles (economic weakness). The results are certainly dependent on the specific time period or regime, and also on the assumption made for how to handle the periods of neutral policy (i.e. no change in FFR).

Future Investigation #

A couple of ideas sound intriguing for future investigation:

  • Does a commodity index (such as GSCI) exhibit differing behavior than gold?
  • How does leverage affect the returns that are observed for the strategy portfolio, stocks, and bonds?
  • Do other Fed tightening/loosening cycles exhibit the same behavior for returns?

References #

  1. https://fred.stlouisfed.org/series/DFEDTARU
  2. https://fred.stlouisfed.org/series/DFEDTARL
  3. https://fred.stlouisfed.org/series/DFEDTAR

Code #

The Jupyter notebook with the functions and all other code is available here.
The HTML export of the jupyter notebook is available here.
The PDF export of the jupyter notebook is available here.