Skip to main content

Performance Of Various Asset Classes During Fed Policy Cycles

··8752 words·42 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. We’ll pull data for stocks, bonds (IG and HY), and gold, and analyze their returns during different phases of the Fed Funds cycle. We’ll also establish a simple strategy and allocate capital to a specific asset based on the current phase of the cycle, and evaluate the performance of this strategy over time.

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

# Suppress warnings
warnings.filterwarnings("ignore")
# 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/easing 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_time_series: Plot the time series 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_time_series import plot_time_series
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(4)

# 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.1025NaNNaN
1982-10-310.0950NaNNaN
1982-11-300.0900NaNNaN
1982-12-310.0850NaNNaN
1983-01-310.0850NaNNaN
............
2025-11-30NaN0.04000.0375
2025-12-31NaN0.03750.0350
2026-01-31NaN0.03750.0350
2026-02-28NaN0.03750.0350
2026-03-31NaN0.03750.0350

523 rows × 3 columns

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

plot_time_series(
    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=30,
    y_label="Rate (%)",
    y_format="Percentage",
    y_format_decimal_places=0,
    y_tick_spacing=0.01,
    y_tick_rotation=0,
    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.1025NaN
1982-10-310.0950-0.0075
1982-11-300.0900-0.0050
1982-12-310.0850-0.0050
1983-01-310.08500.0000
.........
2025-11-300.04000.0000
2025-12-310.0375-0.0025
2026-01-310.03750.0000
2026-02-280.03750.0000
2026-03-310.03750.0000

523 rows × 2 columns

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

plot_time_series(
    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=30,
    y_label="Rate Change (%)",
    y_format="Percentage",
    y_format_decimal_places=2,
    y_tick_spacing=0.0025,
    y_tick_rotation=0,
    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.08251990-01-310.08250.0000Neutral
11990-01-310.08251990-02-280.08250.0000Neutral
21990-02-280.08251990-03-310.08250.0000Neutral
31990-03-310.08251990-04-300.08250.0000Neutral
41990-04-300.08251990-05-310.08250.0000Neutral
.....................
4292025-09-300.04252025-10-310.0400-0.0025Easing
4302025-10-310.04002025-11-300.04000.0000Neutral
4312025-11-300.04002025-12-310.0375-0.0025Easing
4322025-12-310.03752026-01-310.03750.0000Neutral
4332026-01-310.0375NaTNaNNaNNeutral

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.08250.0825
1Easing1990-06-301990-07-310.08250.0800
2Neutral1990-07-311990-09-300.08000.0800
3Easing1990-09-301991-04-300.08000.0575
4Neutral1991-04-301991-07-310.05750.0575
..................
116Neutral2024-12-312025-08-310.04500.0450
117Easing2025-08-312025-10-310.04500.0400
118Neutral2025-10-312025-11-300.04000.0400
119Easing2025-11-302025-12-310.04000.0375
120Neutral2025-12-312026-01-310.03750.0375

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.08250.08250.0000Cycle 1
1Easing1990-06-301993-03-310.08250.0300-0.0525Cycle 2
2Modified Tightening1993-03-311994-01-310.03000.03000.0000Cycle 3
3Tightening1994-01-311995-06-300.03000.06000.0300Cycle 4
4Easing1995-06-301996-07-310.06000.0525-0.0075Cycle 5
5Modified Tightening1996-07-311997-02-280.05250.05250.0000Cycle 6
6Tightening1997-02-281997-09-300.05250.05500.0025Cycle 7
7Modified Tightening1997-09-301998-08-310.05500.05500.0000Cycle 8
8Easing1998-08-311999-05-310.05500.0475-0.0075Cycle 9
9Tightening1999-05-312000-11-300.04750.06500.0175Cycle 10
10Modified Tightening2000-11-302000-12-310.06500.06500.0000Cycle 11
11Easing2000-12-312002-06-300.06500.0175-0.0475Cycle 12
12Modified Tightening2002-06-302002-10-310.01750.01750.0000Cycle 13
13Easing2002-10-312003-12-310.01750.0100-0.0075Cycle 14
14Modified Tightening2003-12-312004-05-310.01000.01000.0000Cycle 15
15Tightening2004-05-312006-12-310.01000.05250.0425Cycle 16
16Modified Tightening2006-12-312007-08-310.05250.05250.0000Cycle 17
17Easing2007-08-312009-07-310.05250.0025-0.0500Cycle 18
18Modified Tightening2009-07-312015-11-300.00250.00250.0000Cycle 19
19Tightening2015-11-302016-06-300.00250.00500.0025Cycle 20
20Modified Tightening2016-06-302016-11-300.00500.00500.0000Cycle 21
21Tightening2016-11-302019-06-300.00500.02500.0200Cycle 22
22Modified Tightening2019-06-302019-07-310.02500.02500.0000Cycle 23
23Easing2019-07-312020-09-300.02500.0025-0.0225Cycle 24
24Modified Tightening2020-09-302022-02-280.00250.00250.0000Cycle 25
25Tightening2022-02-282024-01-310.00250.05500.0525Cycle 26
26Modified Tightening2024-01-312024-08-310.05500.05500.0000Cycle 27
27Easing2024-08-312025-06-300.05500.0450-0.0100Cycle 28
28Modified Tightening2025-06-302025-08-310.04500.04500.0000Cycle 29
29Easing2025-08-312026-01-310.04500.0375-0.0075Cycle 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):

pandas_set_decimal_places(2)

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.94-0.07
1990-02-28358.500.01
1990-03-313680.03
1990-04-30358.81-0.02
1990-05-31393.800.10
.........
2025-09-3014826.800.04
2025-10-3115173.950.02
2025-11-3015211.140.00
2025-12-3115220.450.00
2026-01-3115441.150.01

433 rows × 2 columns

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

plot_time_series(
    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=30,
    y_label="Price ($)",
    y_format="Decimal",
    y_format_decimal_places=0,
    y_tick_spacing=1000,
    y_tick_rotation=0,
    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.033.090.010.630.066.280.190.000.000.000.00Cycle 1, 1989-12-31 to 1990-06-30
1Cycle 21990-06-301993-03-31340.3736.800.011.000.1211.690.13-0.05-525.00-0.02-185.29Cycle 2, 1990-06-30 to 1993-03-31
2Cycle 31993-03-311994-01-31110.1111.360.011.000.1212.450.070.000.000.000.00Cycle 3, 1993-03-31 to 1994-01-31
3Cycle 41994-01-311995-06-30180.2221.800.011.140.1414.050.100.03300.000.02200.00Cycle 4, 1994-01-31 to 1995-06-30
4Cycle 51995-06-301996-07-31140.2323.230.021.530.2019.610.08-0.01-75.00-0.01-64.29Cycle 5, 1995-06-30 to 1996-07-31
5Cycle 61996-07-311997-02-2880.2019.590.022.340.3130.780.140.000.000.000.00Cycle 6, 1996-07-31 to 1997-02-28
6Cycle 71997-02-281997-09-3080.2222.020.032.630.3534.780.180.0025.000.0037.50Cycle 7, 1997-02-28 to 1997-09-30
7Cycle 81997-09-301998-08-31120.088.090.010.810.088.090.200.000.000.000.00Cycle 8, 1997-09-30 to 1998-08-31
8Cycle 91998-08-311999-05-31100.1817.550.021.850.2121.420.24-0.01-75.00-0.01-90.00Cycle 9, 1998-08-31 to 1999-05-31
9Cycle 101999-05-312000-11-30190.000.400.000.130.000.250.160.02175.000.01110.53Cycle 10, 1999-05-31 to 2000-11-30
10Cycle 112000-11-302000-12-312-0.07-7.43-0.04-3.70-0.37-37.090.210.000.000.000.00Cycle 11, 2000-11-30 to 2000-12-31
11Cycle 122000-12-312002-06-3019-0.23-23.11-0.01-1.25-0.15-15.290.17-0.05-475.00-0.03-300.00Cycle 12, 2000-12-31 to 2002-06-30
12Cycle 132002-06-302002-10-315-0.16-16.41-0.03-3.27-0.35-34.960.280.000.000.000.00Cycle 13, 2002-06-30 to 2002-10-31
13Cycle 142002-10-312003-12-31150.4039.540.022.330.3130.550.14-0.01-75.00-0.01-60.00Cycle 14, 2002-10-31 to 2003-12-31
14Cycle 152003-12-312004-05-3160.076.790.011.130.1414.040.090.000.000.000.00Cycle 15, 2003-12-31 to 2004-05-31
15Cycle 162004-05-312006-12-31320.3534.570.010.950.1211.780.070.04425.000.02159.38Cycle 16, 2004-05-31 to 2006-12-31
16Cycle 172006-12-312007-08-3190.076.670.010.750.098.990.090.000.000.000.00Cycle 17, 2006-12-31 to 2007-08-31
17Cycle 182007-08-312009-07-3124-0.29-28.84-0.01-1.19-0.16-15.640.23-0.05-500.00-0.03-250.00Cycle 18, 2007-08-31 to 2009-07-31
18Cycle 192009-07-312015-11-30771.59159.040.011.310.1615.990.130.000.000.000.00Cycle 19, 2009-07-31 to 2015-11-30
19Cycle 202015-11-302016-06-3080.032.500.000.360.043.780.110.0025.000.0037.50Cycle 20, 2015-11-30 to 2016-06-30
20Cycle 212016-06-302016-11-3060.066.010.011.000.1212.380.080.000.000.000.00Cycle 21, 2016-06-30 to 2016-11-30
21Cycle 222016-11-302019-06-30320.4646.030.011.260.1515.260.130.02200.000.0175.00Cycle 22, 2016-11-30 to 2019-06-30
22Cycle 232019-06-302019-07-3120.098.590.044.240.6463.930.140.000.000.000.00Cycle 23, 2019-06-30 to 2019-07-31
23Cycle 242019-07-312020-09-30150.1717.100.011.230.1313.460.21-0.02-225.00-0.02-180.00Cycle 24, 2019-07-31 to 2020-09-30
24Cycle 252020-09-302022-02-28180.2827.730.011.460.1817.720.150.000.000.000.00Cycle 25, 2020-09-30 to 2022-02-28
25Cycle 262022-02-282024-01-31240.1110.890.010.580.055.310.190.05525.000.03262.50Cycle 26, 2022-02-28 to 2024-01-31
26Cycle 272024-01-312024-08-3180.2019.530.022.290.3130.680.100.000.000.000.00Cycle 27, 2024-01-31 to 2024-08-31
27Cycle 282024-08-312025-06-30110.1413.780.011.240.1515.120.13-0.01-100.00-0.01-109.09Cycle 28, 2024-08-31 to 2025-06-30
28Cycle 292025-06-302025-08-3130.109.620.033.120.4444.410.060.000.000.000.00Cycle 29, 2025-06-30 to 2025-08-31
29Cycle 302025-08-312026-01-3160.1010.130.021.630.2121.290.05-0.01-75.00-0.01-150.00Cycle 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:                 Mon, 23 Mar 2026   Prob (F-statistic):              0.495
Time:                         22:08:21   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.01-0.02
1990-02-2897.99-0.00
1990-03-3197.99-0.00
1990-04-3096.61-0.01
1990-05-3199.650.03
.........
2025-09-30645.580.01
2025-10-31650.000.01
2025-11-30656.640.01
2025-12-31652.29-0.01
2026-01-31650.17-0.00

433 rows × 2 columns

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

plot_time_series(
    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=30,
    y_label="Price ($)",
    y_format="Decimal",
    y_format_decimal_places=0,
    y_tick_spacing=50,
    y_tick_rotation=0,
    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.011.360.000.240.032.740.070.000.000.000.00Cycle 1, 1989-12-31 to 1990-06-30
1Cycle 21990-06-301993-03-31340.4645.680.011.120.1414.200.05-0.05-525.00-0.02-185.29Cycle 2, 1990-06-30 to 1993-03-31
2Cycle 31993-03-311994-01-31110.098.880.010.780.109.720.040.000.000.000.00Cycle 3, 1993-03-31 to 1994-01-31
3Cycle 41994-01-311995-06-30180.087.800.000.440.055.130.070.03300.000.02200.00Cycle 4, 1994-01-31 to 1995-06-30
4Cycle 51995-06-301996-07-31140.054.780.000.340.044.090.05-0.01-75.00-0.01-64.29Cycle 5, 1995-06-30 to 1996-07-31
5Cycle 61996-07-311997-02-2880.055.300.010.660.088.060.050.000.000.000.00Cycle 6, 1996-07-31 to 1997-02-28
6Cycle 71997-02-281997-09-3080.066.450.010.800.109.830.060.0025.000.0037.50Cycle 7, 1997-02-28 to 1997-09-30
7Cycle 81997-09-301998-08-31120.1414.430.011.140.1414.430.040.000.000.000.00Cycle 8, 1997-09-30 to 1998-08-31
8Cycle 91998-08-311999-05-31100.033.050.000.330.043.670.08-0.01-75.00-0.01-90.00Cycle 9, 1998-08-31 to 1999-05-31
9Cycle 101999-05-312000-11-30190.098.740.000.450.055.440.040.02175.000.01110.53Cycle 10, 1999-05-31 to 2000-11-30
10Cycle 112000-11-302000-12-3120.054.920.022.430.3333.390.000.000.000.000.00Cycle 11, 2000-11-30 to 2000-12-31
11Cycle 122000-12-312002-06-30190.1414.330.010.720.098.830.06-0.05-475.00-0.03-300.00Cycle 12, 2000-12-31 to 2002-06-30
12Cycle 132002-06-302002-10-3150.109.940.021.930.2625.550.070.000.000.000.00Cycle 13, 2002-06-30 to 2002-10-31
13Cycle 142002-10-312003-12-31150.022.160.000.170.021.730.09-0.01-75.00-0.01-60.00Cycle 14, 2002-10-31 to 2003-12-31
14Cycle 152003-12-312004-05-316-0.00-0.090.000.01-0.00-0.180.080.000.000.000.00Cycle 15, 2003-12-31 to 2004-05-31
15Cycle 162004-05-312006-12-31320.1010.270.000.310.043.730.040.04425.000.02159.38Cycle 16, 2004-05-31 to 2006-12-31
16Cycle 172006-12-312007-08-3190.033.300.000.370.044.420.050.000.000.000.00Cycle 17, 2006-12-31 to 2007-08-31
17Cycle 182007-08-312009-07-31240.2019.840.010.790.099.470.09-0.05-500.00-0.03-250.00Cycle 18, 2007-08-31 to 2009-07-31
18Cycle 192009-07-312015-11-30770.3938.990.000.440.055.270.060.000.000.000.00Cycle 19, 2009-07-31 to 2015-11-30
19Cycle 202015-11-302016-06-3080.076.790.010.830.1010.360.050.0025.000.0037.50Cycle 20, 2015-11-30 to 2016-06-30
20Cycle 212016-06-302016-11-306-0.03-3.17-0.01-0.51-0.06-6.250.080.000.000.000.00Cycle 21, 2016-06-30 to 2016-11-30
21Cycle 222016-11-302019-06-30320.065.920.000.190.022.180.050.02200.000.0175.00Cycle 22, 2016-11-30 to 2019-06-30
22Cycle 232019-06-302019-07-3120.011.310.010.650.088.090.030.000.000.000.00Cycle 23, 2019-06-30 to 2019-07-31
23Cycle 242019-07-312020-09-30150.1313.080.010.840.1010.340.06-0.02-225.00-0.02-180.00Cycle 24, 2019-07-31 to 2020-09-30
24Cycle 252020-09-302022-02-2818-0.07-6.52-0.00-0.37-0.04-4.400.040.000.000.000.00Cycle 25, 2020-09-30 to 2022-02-28
25Cycle 262022-02-282024-01-3124-0.10-9.88-0.00-0.39-0.05-5.070.100.05525.000.03262.50Cycle 26, 2022-02-28 to 2024-01-31
26Cycle 272024-01-312024-08-3180.032.750.000.360.044.150.070.000.000.000.00Cycle 27, 2024-01-31 to 2024-08-31
27Cycle 282024-08-312025-06-30110.033.290.000.310.043.600.06-0.01-100.00-0.01-109.09Cycle 28, 2024-08-31 to 2025-06-30
28Cycle 292025-06-302025-08-3130.032.670.010.890.1111.110.040.000.000.000.00Cycle 29, 2025-06-30 to 2025-08-31
29Cycle 302025-08-312026-01-3160.033.080.010.510.066.260.03-0.01-75.00-0.01-150.00Cycle 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:                 Mon, 23 Mar 2026   Prob (F-statistic):              0.237
Time:                         22:08:23   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.21-0.02
1990-02-28190.20-0.02
1990-03-31195.190.03
1990-04-30194.86-0.00
1990-05-31198.620.02
.........
2025-09-302876.850.01
2025-10-312881.380.00
2025-11-302898.070.01
2025-12-312914.490.01
2026-01-312929.320.01

433 rows × 2 columns

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

plot_time_series(
    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=30,
    y_label="Price ($)",
    y_format="Decimal",
    y_format_decimal_places=0,
    y_tick_spacing=200,
    y_tick_rotation=0,
    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.022.490.000.430.055.050.080.000.000.000.00Cycle 1, 1989-12-31 to 1990-06-30
1Cycle 21990-06-301993-03-31340.6262.150.011.480.1918.600.11-0.05-525.00-0.02-185.29Cycle 2, 1990-06-30 to 1993-03-31
2Cycle 31993-03-311994-01-31110.1414.260.011.220.1615.660.020.000.000.000.00Cycle 3, 1993-03-31 to 1994-01-31
3Cycle 41994-01-311995-06-30180.1111.250.010.610.077.370.060.03300.000.02200.00Cycle 4, 1994-01-31 to 1995-06-30
4Cycle 51995-06-301996-07-31140.1110.890.010.740.099.270.02-0.01-75.00-0.01-64.29Cycle 5, 1995-06-30 to 1996-07-31
5Cycle 61996-07-311997-02-2880.1010.480.011.260.1616.120.020.000.000.000.00Cycle 6, 1996-07-31 to 1997-02-28
6Cycle 71997-02-281997-09-3080.109.560.011.160.1514.670.050.0025.000.0037.50Cycle 7, 1997-02-28 to 1997-09-30
7Cycle 81997-09-301998-08-31120.033.220.000.280.033.220.070.000.000.000.00Cycle 8, 1997-09-30 to 1998-08-31
8Cycle 91998-08-311999-05-3110-0.01-0.73-0.00-0.04-0.01-0.870.09-0.01-75.00-0.01-90.00Cycle 9, 1998-08-31 to 1999-05-31
9Cycle 101999-05-312000-11-3019-0.09-8.92-0.00-0.48-0.06-5.730.050.02175.000.01110.53Cycle 10, 1999-05-31 to 2000-11-30
10Cycle 112000-11-302000-12-312-0.02-2.11-0.01-1.01-0.12-11.980.140.000.000.000.00Cycle 11, 2000-11-30 to 2000-12-31
11Cycle 122000-12-312002-06-30190.022.120.000.170.011.330.12-0.05-475.00-0.03-300.00Cycle 12, 2000-12-31 to 2002-06-30
12Cycle 132002-06-302002-10-315-0.11-10.87-0.02-2.22-0.24-24.140.130.000.000.000.00Cycle 13, 2002-06-30 to 2002-10-31
13Cycle 142002-10-312003-12-31150.3837.660.022.170.2929.140.07-0.01-75.00-0.01-60.00Cycle 14, 2002-10-31 to 2003-12-31
14Cycle 152003-12-312004-05-3160.022.190.000.370.044.420.050.000.000.000.00Cycle 15, 2003-12-31 to 2004-05-31
15Cycle 162004-05-312006-12-31320.2625.620.010.720.098.930.040.04425.000.02159.38Cycle 16, 2004-05-31 to 2006-12-31
16Cycle 172006-12-312007-08-3190.021.680.000.200.022.250.060.000.000.000.00Cycle 17, 2006-12-31 to 2007-08-31
17Cycle 182007-08-312009-07-31240.054.910.000.370.022.420.20-0.05-500.00-0.03-250.00Cycle 18, 2007-08-31 to 2009-07-31
18Cycle 192009-07-312015-11-30770.8383.150.010.810.109.890.070.000.000.000.00Cycle 19, 2009-07-31 to 2015-11-30
19Cycle 202015-11-302016-06-3080.043.950.010.520.065.980.090.0025.000.0037.50Cycle 20, 2015-11-30 to 2016-06-30
20Cycle 212016-06-302016-11-3060.066.430.011.050.1313.270.040.000.000.000.00Cycle 21, 2016-06-30 to 2016-11-30
21Cycle 222016-11-302019-06-30320.1717.310.010.510.066.170.040.02200.000.0175.00Cycle 22, 2016-11-30 to 2019-06-30
22Cycle 232019-06-302019-07-3120.032.860.011.420.1818.410.040.000.000.000.00Cycle 23, 2019-06-30 to 2019-07-31
23Cycle 242019-07-312020-09-30150.054.630.000.370.043.690.13-0.02-225.00-0.02-180.00Cycle 24, 2019-07-31 to 2020-09-30
24Cycle 252020-09-302022-02-28180.076.770.000.370.044.470.050.000.000.000.00Cycle 25, 2020-09-30 to 2022-02-28
25Cycle 262022-02-282024-01-31240.043.580.000.190.021.770.100.05525.000.03262.50Cycle 26, 2022-02-28 to 2024-01-31
26Cycle 272024-01-312024-08-3180.066.280.010.770.109.570.030.000.000.000.00Cycle 27, 2024-01-31 to 2024-08-31
27Cycle 282024-08-312025-06-30110.088.180.010.720.098.960.04-0.01-100.00-0.01-109.09Cycle 28, 2024-08-31 to 2025-06-30
28Cycle 292025-06-302025-08-3130.043.580.011.180.1515.090.020.000.000.000.00Cycle 29, 2025-06-30 to 2025-08-31
29Cycle 302025-08-312026-01-3160.043.940.010.650.088.030.01-0.01-75.00-0.01-150.00Cycle 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:                 Mon, 23 Mar 2026   Prob (F-statistic):              0.735
Time:                         22:08:25   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.050.03
1990-02-28407.70-0.02
1990-03-31368.50-0.10
1990-04-30367.75-0.00
1990-05-31363.05-0.01
.........
2025-09-303858.960.12
2025-10-314002.920.04
2025-11-304239.430.06
2025-12-314319.370.02
2026-01-314894.230.13

433 rows × 2 columns

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

plot_time_series(
    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=30,
    y_label="Price ($)",
    y_format="Decimal",
    y_format_decimal_places=0,
    y_tick_spacing=400,
    y_tick_rotation=0,
    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.12-12.22-0.02-2.07-0.23-22.950.150.000.000.000.00Cycle 1, 1989-12-31 to 1990-06-30
1Cycle 21990-06-301993-03-3134-0.07-6.62-0.00-0.16-0.02-2.390.10-0.05-525.00-0.02-185.29Cycle 2, 1990-06-30 to 1993-03-31
2Cycle 31993-03-311994-01-31110.1615.900.011.460.1717.470.170.000.000.000.00Cycle 3, 1993-03-31 to 1994-01-31
3Cycle 41994-01-311995-06-3018-0.02-1.56-0.00-0.07-0.01-1.040.070.03300.000.02200.00Cycle 4, 1994-01-31 to 1995-06-30
4Cycle 51995-06-301996-07-31140.010.720.000.070.010.610.06-0.01-75.00-0.01-64.29Cycle 5, 1995-06-30 to 1996-07-31
5Cycle 61996-07-311997-02-288-0.04-4.47-0.01-0.52-0.07-6.630.120.000.000.000.00Cycle 6, 1996-07-31 to 1997-02-28
6Cycle 71997-02-281997-09-308-0.03-2.87-0.00-0.31-0.04-4.280.120.0025.000.0037.50Cycle 7, 1997-02-28 to 1997-09-30
7Cycle 81997-09-301998-08-3112-0.15-14.99-0.01-1.28-0.15-14.990.120.000.000.000.00Cycle 8, 1997-09-30 to 1998-08-31
8Cycle 91998-08-311999-05-3110-0.06-5.62-0.01-0.52-0.07-6.710.13-0.01-75.00-0.01-90.00Cycle 9, 1998-08-31 to 1999-05-31
9Cycle 101999-05-312000-11-3019-0.06-5.62-0.00-0.19-0.04-3.590.170.02175.000.01110.53Cycle 10, 1999-05-31 to 2000-11-30
10Cycle 112000-11-302000-12-3120.032.680.011.330.1717.180.030.000.000.000.00Cycle 11, 2000-11-30 to 2000-12-31
11Cycle 122000-12-312002-06-30190.1616.270.010.840.109.990.11-0.05-475.00-0.03-300.00Cycle 12, 2000-12-31 to 2002-06-30
12Cycle 132002-06-302002-10-315-0.03-2.69-0.00-0.50-0.06-6.350.120.000.000.000.00Cycle 13, 2002-06-30 to 2002-10-31
13Cycle 142002-10-312003-12-31150.2828.400.021.770.2222.140.15-0.01-75.00-0.01-60.00Cycle 14, 2002-10-31 to 2003-12-31
14Cycle 152003-12-312004-05-316-0.01-0.650.000.04-0.01-1.300.210.000.000.000.00Cycle 15, 2003-12-31 to 2004-05-31
15Cycle 162004-05-312006-12-31320.6564.630.021.650.2120.560.150.04425.000.02159.38Cycle 16, 2004-05-31 to 2006-12-31
16Cycle 172006-12-312007-08-3190.043.900.000.450.055.240.070.000.000.000.00Cycle 17, 2006-12-31 to 2007-08-31
17Cycle 182007-08-312009-07-31240.4443.610.021.770.2019.840.25-0.05-500.00-0.03-250.00Cycle 18, 2007-08-31 to 2009-07-31
18Cycle 192009-07-312015-11-30770.1514.920.000.320.022.190.180.000.000.000.00Cycle 19, 2009-07-31 to 2015-11-30
19Cycle 202015-11-302016-06-3080.1615.740.022.030.2524.520.230.0025.000.0037.50Cycle 20, 2015-11-30 to 2016-06-30
20Cycle 212016-06-302016-11-306-0.03-3.47-0.00-0.45-0.07-6.810.200.000.000.000.00Cycle 21, 2016-06-30 to 2016-11-30
21Cycle 222016-11-302019-06-30320.1010.360.000.360.043.770.110.02200.000.0175.00Cycle 22, 2016-11-30 to 2019-06-30
22Cycle 232019-06-302019-07-3120.088.290.044.130.6161.240.190.000.000.000.00Cycle 23, 2019-06-30 to 2019-07-31
23Cycle 242019-07-312020-09-30150.3433.790.022.040.2626.220.15-0.02-225.00-0.02-180.00Cycle 24, 2019-07-31 to 2020-09-30
24Cycle 252020-09-302022-02-2818-0.03-2.99-0.00-0.08-0.02-2.000.150.000.000.000.00Cycle 25, 2020-09-30 to 2022-02-28
25Cycle 262022-02-282024-01-31240.1313.490.010.610.076.530.140.05525.000.03262.50Cycle 26, 2022-02-28 to 2024-01-31
26Cycle 272024-01-312024-08-3180.2121.350.022.490.3433.680.110.000.000.000.00Cycle 27, 2024-01-31 to 2024-08-31
27Cycle 282024-08-312025-06-30110.3534.950.032.820.3938.680.13-0.01-100.00-0.01-109.09Cycle 28, 2024-08-31 to 2025-06-30
28Cycle 292025-06-302025-08-3130.054.820.021.610.2120.740.100.000.000.000.00Cycle 29, 2025-06-30 to 2025-08-31
29Cycle 302025-08-312026-01-3160.4948.760.076.931.21121.310.16-0.01-75.00-0.01-150.00Cycle 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:                 Mon, 23 Mar 2026   Prob (F-statistic):              0.179
Time:                         22:08:28   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.081990-01-310.080.00NeutralModified Tightening1None
11990-01-310.081990-02-280.080.00NeutralModified Tightening1Modified Tightening
21990-02-280.081990-03-310.080.00NeutralModified Tightening1Modified Tightening
31990-03-310.081990-04-300.080.00NeutralModified Tightening1Modified Tightening
41990-04-300.081990-05-310.080.00NeutralModified Tightening1Modified Tightening
..............................
4292025-09-300.042025-10-310.04-0.00EasingEasing30Easing
4302025-10-310.042025-11-300.040.00NeutralEasing30Easing
4312025-11-300.042025-12-310.04-0.00EasingEasing30Easing
4322025-12-310.042026-01-310.040.00NeutralEasing30Easing
4332026-01-310.04NaTNaNNaNNeutralEasing30Easing

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.94-0.071990-01-311990-02-28Modified Tightening1
11990-02-28358.500.011990-02-281990-03-31Modified Tightening1
21990-03-313680.031990-03-311990-04-30Modified Tightening1
31990-04-30358.81-0.021990-04-301990-05-31Modified Tightening1
41990-05-31393.800.101990-05-311990-06-30Modified Tightening1
........................
4272025-08-3114304.680.022025-08-312025-09-30Modified Tightening30
4282025-09-3014826.800.042025-09-302025-10-31Easing30
4292025-10-3115173.950.022025-10-312025-11-30Easing30
4302025-11-3015211.140.002025-11-302025-12-31Easing30
4312025-12-3115220.450.002025-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.01-0.021990-01-311990-02-28Modified Tightening1
11990-02-2897.99-0.001990-02-281990-03-31Modified Tightening1
21990-03-3197.99-0.001990-03-311990-04-30Modified Tightening1
31990-04-3096.61-0.011990-04-301990-05-31Modified Tightening1
41990-05-3199.650.031990-05-311990-06-30Modified Tightening1
........................
4272025-08-31641.260.022025-08-312025-09-30Modified Tightening30
4282025-09-30645.580.012025-09-302025-10-31Easing30
4292025-10-31650.000.012025-10-312025-11-30Easing30
4302025-11-30656.640.012025-11-302025-12-31Easing30
4312025-12-31652.29-0.012025-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.94-0.071990-01-311990-02-28Modified Tightening1-0.07
11990-02-28358.500.011990-02-281990-03-31Modified Tightening10.01
21990-03-313680.031990-03-311990-04-30Modified Tightening10.03
31990-04-30358.81-0.021990-04-301990-05-31Modified Tightening1-0.02
41990-05-31393.800.101990-05-311990-06-30Modified Tightening10.10
51990-06-30391.14-0.011990-06-301990-07-31Modified Tightening2-0.01
61990-07-31102.840.011990-07-311990-08-31Easing20.01
71990-08-31100.78-0.021990-08-311990-09-30Easing2-0.02
81990-09-30101.740.011990-09-301990-10-31Easing20.01
91990-10-31103.650.021990-10-311990-11-30Easing20.02
101990-11-30106.430.031990-11-301990-12-31Easing20.03
111990-12-31108.250.021990-12-311991-01-31Easing20.02
121991-01-31109.530.011991-01-311991-02-28Easing20.01
131991-02-28110.100.011991-02-281991-03-31Easing20.01
141991-03-31110.430.001991-03-311991-04-30Easing20.00
151991-04-30111.600.011991-04-301991-05-31Easing20.01
161991-05-31112.080.001991-05-311991-06-30Easing20.00
171991-06-30111.50-0.011991-06-301991-07-31Easing2-0.01
181991-07-31113.050.011991-07-311991-08-31Easing20.01
191991-08-31116.060.031991-08-311991-09-30Easing20.03
# 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.07-0.070.00-0.07-0.070.00-0.02-0.020.00
1990-02-280.01-0.060.000.01-0.060.00-0.00-0.02-0.00
1990-03-310.03-0.030.000.03-0.030.00-0.00-0.02-0.00
1990-04-30-0.02-0.05-0.02-0.02-0.05-0.02-0.01-0.03-0.01
1990-05-310.100.040.000.100.040.000.03-0.000.00
..............................
2025-08-310.0242.290.000.0236.700.000.025.41-0.11
2025-09-300.0142.580.000.0438.080.000.015.46-0.11
2025-10-310.0142.880.000.0238.990.000.015.50-0.10
2025-11-300.0143.320.000.0039.090.000.015.57-0.09
2025-12-31-0.0143.03-0.010.0039.120.00-0.015.52-0.10

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_time_series(
    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=30,
    y_label="Return",
    y_format="Decimal",
    y_format_decimal_places=2,
    y_tick_spacing=0.02,
    y_tick_rotation=0,
    grid=True,
    legend=True,
    export_plot=False,
    plot_file_name=None,
)

png

And cumulative returns:

plot_time_series(
    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=30,
    y_label="Cumulative Return",
    y_format="Decimal",
    y_format_decimal_places=0,
    y_tick_spacing=3,
    y_tick_rotation=0,
    grid=True,
    legend=True,
    export_plot=False,
    plot_file_name=None,
)

png

And drawdowns:

plot_time_series(
    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=30,
    y_label="Drawdown (%)",
    y_format="Decimal",
    y_format_decimal_places=2,
    y_tick_spacing=0.05,
    y_tick_rotation=0,
    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 DateCalendar Days to RecoveryMAR Ratio
Portfolio_Monthly_Return0.110.120.950.110.112020-11-30-0.141998-08-31-0.242021-12-312022-09-302023-12-31457.000.46
SPXT_Monthly_Return0.110.150.770.110.132020-04-30-0.172008-10-31-0.512007-10-312009-02-282012-03-311127.000.21
10Y_Monthly_Return0.050.060.860.050.082008-11-30-0.062003-07-31-0.232020-07-312023-10-31NaTNaN0.23

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

On paper, the backtest looks like it would have been a successful strategy, but there are certainly some caveats to consider. First, the strategy is based on the assumption that we can accurately identify the Fed tightening and easing cycles in real-time, which may not be the case. Second, the strategy does not account for transaction costs or taxes, which could significantly impact the returns. Finally, the strategy is based on historical data, and there is no guarantee that the same patterns will hold in the future.

The strategy has done well post-GFC, but the period from 2008 - present has been marked by historically low interest rates and a strong bull market in stocks, which may not be representative of future market conditions based on current valuations (present time February 2026). Additionally, the current rate cutting cycle (cycles 28/29/30) is still ongoing, and it remains to be seen how the strategy will perform during this cycle and in the subsequent cycles.

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/easing 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.