Skip to main content

Performance Of Various Asset Classes During Fed Policy Cycles

··4811 words·23 mins

Introduction #

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

Python Functions #

Here are the functions needed for this project:

  • calc_fed_cycle_asset_performance: Calculates metrics for an asset based on a specified Fed tightening/loosening cycle.
  • df_info: A simple function to display the information about a DataFrame and the first five rows and last five rows.
  • df_info_markdown: Similar to the df_info function above, except that it coverts the output to markdown.
  • export_track_md_deps: Exports various text outputs to markdown files, which are included in the index.md file created when building the site with Hugo.
  • 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.
  • yf_pull_data: Download daily price data from Yahoo Finance and export it.

Data Overview #

Acquire & Plot Fed Funds Data #

First, let’s get the data for the Fed Funds rate (FFR):

# Set decimal places
pandas_set_decimal_places(4)

# Pull Effective Fed Funds Rate from FRED
fedfunds = web.DataReader("FEDFUNDS", "fred", start="1900-01-01", end=datetime.today())
fedfunds["FEDFUNDS"] = fedfunds["FEDFUNDS"] / 100  # Convert to decimal

# Resample to monthly frequency and compute change in rate
fedfunds_monthly = fedfunds.resample("M").last()
fedfunds_monthly = fedfunds_monthly[(fedfunds_monthly.index >= pd.to_datetime(start_date)) & (fedfunds_monthly.index <= pd.to_datetime(end_date))]
fedfunds_monthly["FedFunds_Change"] = fedfunds_monthly["FEDFUNDS"].diff()

This gives us:

The columns, shape, and data types are:

<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 252 entries, 2004-11-30 to 2025-10-31
Freq: ME
Data columns (total 2 columns):
 #   Column           Non-Null Count  Dtype  
---  ------           --------------  -----  
 0   FEDFUNDS         252 non-null    float64
 1   FedFunds_Change  251 non-null    float64
dtypes: float64(2)
memory usage: 5.9 KB

The first 5 rows are:

DATEFEDFUNDSFedFunds_Change
2004-11-30 00:00:000.0193nan
2004-12-31 00:00:000.02160.0023
2005-01-31 00:00:000.02280.0012
2005-02-28 00:00:000.02500.0022
2005-03-31 00:00:000.02630.0013

The last 5 rows are:

DATEFEDFUNDSFedFunds_Change
2025-06-30 00:00:000.04330.0000
2025-07-31 00:00:000.04330.0000
2025-08-31 00:00:000.04330.0000
2025-09-30 00:00:000.0422-0.0011
2025-10-31 00:00:000.0409-0.0013

We can then generate several useful visual aids (plots). First, the FFR from the beginning of our data set (11/2004):

plot_timeseries(
    price_df=fedfunds_monthly,
    plot_start_date=start_date,
    plot_end_date=end_date,
    plot_columns=["FEDFUNDS"],
    title="Fed Funds Rate",
    x_label="Date",
    x_format="Year",
    y_label="Rate (%)",
    y_format="Percentage",
    y_format_decimal_places=1,
    y_tick_spacing=0.005,
    grid=True,
    legend=False,
    export_plot=True,
    plot_file_name="01_Fed_Funds_Rate",
)

Fed Funds Rate

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

plot_timeseries(
    price_df=fedfunds_monthly,
    plot_start_date=start_date,
    plot_end_date=end_date,
    plot_columns=["FedFunds_Change"],
    title="Fed Funds Change In Rate",
    x_label="Date",
    x_format="Year",
    y_label="Rate (%)",
    y_format="Percentage",
    y_format_decimal_places=2,
    y_tick_spacing=0.0025,
    grid=True,
    legend=False,
    export_plot=True,
    plot_file_name="01_Fed_Funds_Change_In_Rate",
)

Change In Fed Funds Rate

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 loosening cycles. This is done via visual inspection of the FFR plot and establishing some timeframes for when the cycles started and ended. Here’s the list of cycles:

# Define manually specified Fed policy cycles
fed_cycles = [
    ("2004-11-01", "2006-07-01"),
    ("2006-07-01", "2007-07-01"),
    ("2007-07-01", "2008-12-01"),
    ("2008-12-01", "2015-11-01"),
    ("2015-11-01", "2019-01-01"),
    ("2019-01-01", "2019-07-01"),
    ("2019-07-01", "2020-04-01"),
    ("2020-04-01", "2022-02-01"),
    ("2022-02-01", "2023-08-01"),
    ("2023-08-01", "2024-08-01"),
    ("2024-08-01", datetime.today().strftime('%Y-%m-%d')),
]

# Optional: assign a name to each cycle
cycle_labels = [f"Cycle {i+1}" for i in range(len(fed_cycles))]

And here’s the list of total change in the FFR corresponding to each cycle:

# Set decimal places
pandas_set_decimal_places(4)

#  Calc changes by fed cycle defined above
fed_changes = []

for (start, end) in fed_cycles:
    start = pd.to_datetime(start)
    end = pd.to_datetime(end)

    try:
        rate_start = fedfunds.loc[start, "FEDFUNDS"]
    except KeyError:
        rate_start = fedfunds.loc[:start].iloc[-1]["FEDFUNDS"]

    try:
        rate_end = fedfunds.loc[end, "FEDFUNDS"]
    except KeyError:
        rate_end = fedfunds.loc[:end].iloc[-1]["FEDFUNDS"]

    change = rate_end - rate_start
    fed_changes.append(change)

fed_changes_df = pd.DataFrame({
    "Cycle": cycle_labels,
    "FedFunds_Change": fed_changes
})

Which gives us the following cycles and cumulative change in rate per cycle:

CycleFedFunds_Change
0Cycle 10.0331
1Cycle 20.0002
2Cycle 3-0.0510
3Cycle 4-0.0004
4Cycle 50.0228
5Cycle 60.0000
6Cycle 7-0.0235
7Cycle 80.0003
8Cycle 90.0525
9Cycle 100.0000
10Cycle 11-0.0161

Return Performance By Fed Policy Cycle #

Moving on, we will now look at the performance of three (3) different asset classes during each Fed cycle. We’ll use SPY as a proxy for stocks, TLT as a proxy for bonds, and GLD as a proxy for gold. These datasets are slightly limiting due to the availability of all 3 starting in late 2004, but will work for our simple exercise. In a future post, we’ll look to use Bloomberg indices instead.

Stocks (SPY) #

First, we pull data for SPY with the following:

# Set decimal places
pandas_set_decimal_places(2)

yf_pull_data(
    base_directory=DATA_DIR,
    ticker="SPY",
    source="Yahoo_Finance", 
    asset_class="Exchange_Traded_Funds", 
    excel_export=True,
    pickle_export=True,
    output_confirmation=True,
)

And then load data with the following:

spy = load_data(
    base_directory=DATA_DIR,
    ticker="SPY",
    source="Yahoo_Finance", 
    asset_class="Exchange_Traded_Funds",
    timeframe="Daily",
    file_format="pickle",
)

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

# Resample to monthly frequency
spy_monthly = spy.resample("M").last()
spy_monthly["Monthly_Return"] = spy_monthly["Close"].pct_change()

Which gives us the following:

The columns, shape, and data types are:

<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 252 entries, 2004-11-30 to 2025-10-31
Freq: ME
Data columns (total 6 columns):
 #   Column          Non-Null Count  Dtype  
---  ------          --------------  -----  
 0   Close           252 non-null    float64
 1   High            252 non-null    float64
 2   Low             252 non-null    float64
 3   Open            252 non-null    float64
 4   Volume          252 non-null    int64  
 5   Monthly_Return  251 non-null    float64
dtypes: float64(5), int64(1)
memory usage: 13.8 KB

The first 5 rows are:

DateCloseHighLowOpenVolumeMonthly_Return
2004-11-30 00:00:0079.6079.8379.4379.6753685200.00nan
2004-12-31 00:00:0081.9982.5381.9582.2828648800.000.03
2005-01-31 00:00:0080.1580.2279.8580.0152532700.00-0.02
2005-02-28 00:00:0081.8382.2881.4382.1869381300.000.02
2005-03-31 00:00:0080.3380.6780.2780.4964575400.00-0.02

The last 5 rows are:

DateCloseHighLowOpenVolumeMonthly_Return
2025-06-30 00:00:00614.33615.69611.53613.8692502500.000.05
2025-07-31 00:00:00628.48636.20627.17635.81103385200.000.02
2025-08-31 00:00:00641.37644.15639.47643.7874522200.000.02
2025-09-30 00:00:00664.22664.69659.66660.9886288000.000.04
2025-10-31 00:00:00680.05683.06677.24683.0287164100.000.02

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

plot_timeseries(
    price_df=spy,
    plot_start_date=start_date,
    plot_end_date=end_date,
    plot_columns=["Close"],
    title="SPY Close Price",
    x_label="Date",
    x_format="Year",
    y_label="Price ($)",
    y_format="Decimal",
    y_tick_spacing=50,
    grid=True,
    legend=False,
    export_plot=True,
    plot_file_name="02_SPY_Price",
    y_format_decimal_places=0,
)

SPY Price History

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

spy_cycle_df = calc_fed_cycle_asset_performance(
    fed_cycles=fed_cycles,
    cycle_labels=cycle_labels,
    fed_changes=fed_changes,
    monthly_df=spy_monthly,
)

Which gives us:

CycleStartEndMonthsCumulativeReturnCumulativeReturnPctAverageMonthlyReturnAverageMonthlyReturnPctAnnualizedReturnAnnualizedReturnPctVolatilityFedFundsChangeFedFundsChange_bpsFFR_AnnualizedChangeFFR_AnnualizedChange_bpsLabel
0Cycle 12004-11-012006-07-01200.1111.320.010.590.076.640.080.03331.000.02198.60Cycle 1, 2004-11-01 to 2006-07-01
1Cycle 22006-07-012007-07-01120.2020.360.021.570.2020.360.070.002.000.002.00Cycle 2, 2006-07-01 to 2007-07-01
2Cycle 32007-07-012008-12-0117-0.39-38.55-0.03-2.67-0.29-29.090.19-0.05-510.00-0.04-360.00Cycle 3, 2007-07-01 to 2008-12-01
3Cycle 42008-12-012015-11-01831.67167.340.011.280.1515.280.15-0.00-4.00-0.00-0.58Cycle 4, 2008-12-01 to 2015-11-01
4Cycle 52015-11-012019-01-01380.2828.300.010.700.088.190.110.02228.000.0172.00Cycle 5, 2015-11-01 to 2019-01-01
5Cycle 62019-01-012019-07-0160.1818.330.032.950.4040.010.180.000.000.000.00Cycle 6, 2019-01-01 to 2019-07-01
6Cycle 72019-07-012020-04-019-0.11-10.67-0.01-1.10-0.14-13.960.19-0.02-235.00-0.03-313.33Cycle 7, 2019-07-01 to 2020-04-01
7Cycle 82020-04-012022-02-01220.7979.130.032.780.3737.430.160.003.000.001.64Cycle 8, 2020-04-01 to 2022-02-01
8Cycle 92022-02-012023-08-01180.044.180.000.400.032.770.210.05525.000.03350.00Cycle 9, 2022-02-01 to 2023-08-01
9Cycle 102023-08-012024-08-01120.2222.000.021.750.2222.000.150.000.000.000.00Cycle 10, 2023-08-01 to 2024-08-01
10Cycle 112024-08-012026-01-26150.2625.720.021.590.2020.090.11-0.02-161.00-0.01-128.80Cycle 11, 2024-08-01 to 2026-01-26

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:

SPY Cumulative Returns

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

SPY Annualized Returns

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 3 and 7), 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 = spy_cycle_df

####################################
### 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

Which gives us the results of the OLS regression:

                             OLS Regression Results                            
===============================================================================
Dep. Variable:     AnnualizedReturnPct   R-squared:                       0.176
Model:                             OLS   Adj. R-squared:                  0.085
Method:                  Least Squares   F-statistic:                     1.928
Date:                 Mon, 26 Jan 2026   Prob (F-statistic):              0.198
Time:                         12:06:34   Log-Likelihood:                -47.196
No. Observations:                   11   AIC:                             98.39
Df Residuals:                        9   BIC:                             99.19
Df Model:                            1                                         
Covariance Type:             nonrobust                                         
============================================================================================
                               coef    std err          t      P>|t|      [0.025      0.975]
--------------------------------------------------------------------------------------------
const                       12.4815      5.909      2.112      0.064      -0.886      25.849
FFR_AnnualizedChange_bps     0.0424      0.031      1.389      0.198      -0.027       0.112
==============================================================================
Omnibus:                        1.103   Durbin-Watson:                   3.070
Prob(Omnibus):                  0.576   Jarque-Bera (JB):                0.674
Skew:                           0.021   Prob(JB):                        0.714
Kurtosis:                       1.788   Cond. No.                         194.
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.

And then plot the regression line along with the values:

plot_scatter_regression_ffr_vs_returns(
    cycle_df=spy_cycle_df,
    asset_label="SPY",
    index_num="02",
    x_vals=X_vals,
    y_vals=Y_vals,
    intercept=model.params[0],
    slope=model.params[1],
)

Which gives us:

SPY Regression - Annualized Returns On Annualized Change In FFR

Here we can see the data points for cycles 3 and 7 as mentioned above. Ignoring the data points where the annualized change in FFR is roughly zero (cycles 2, 4, 6, 8, and 10), cycles 1, 5, and 9 fit the economic thesis above, and cycle 11 (which is the current rate cutting cycle), stands as an outlier. Of course, the book is not yet finished for cycle 11, and we could certainly see a bear market in stocks over the next several years.

Bonds (TLT) #

Next, we’ll run a similar process for long term bonds using TLT as the proxy.

First, we pull data with the following:

# Set decimal places
pandas_set_decimal_places(2)

yf_pull_data(
    base_directory=DATA_DIR,
    ticker="TLT",
    source="Yahoo_Finance", 
    asset_class="Exchange_Traded_Funds", 
    excel_export=True,
    pickle_export=True,
    output_confirmation=True,
)

And then load data with the following:

tlt = load_data(
    base_directory=DATA_DIR,
    ticker="TLT",
    source="Yahoo_Finance", 
    asset_class="Exchange_Traded_Funds",
    timeframe="Daily",
    file_format="pickle",
)

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

# Resample to monthly frequency
tlt_monthly = tlt.resample("M").last()
tlt_monthly["Monthly_Return"] = tlt_monthly["Close"].pct_change()

Gives us the following:

The columns, shape, and data types are:

<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 252 entries, 2004-11-30 to 2025-10-31
Freq: ME
Data columns (total 6 columns):
 #   Column          Non-Null Count  Dtype  
---  ------          --------------  -----  
 0   Close           252 non-null    float64
 1   High            252 non-null    float64
 2   Low             252 non-null    float64
 3   Open            252 non-null    float64
 4   Volume          252 non-null    int64  
 5   Monthly_Return  251 non-null    float64
dtypes: float64(5), int64(1)
memory usage: 13.8 KB

The first 5 rows are:

DateCloseHighLowOpenVolumeMonthly_Return
2004-11-30 00:00:0043.8043.9143.6443.801754500.00nan
2004-12-31 00:00:0044.9645.0144.8444.871056400.000.03
2005-01-31 00:00:0046.5746.5946.3546.371313900.000.04
2005-02-28 00:00:0045.8846.4345.8246.432797300.00-0.01
2005-03-31 00:00:0045.6745.7045.4345.612410900.00-0.00

The last 5 rows are:

DateCloseHighLowOpenVolumeMonthly_Return
2025-06-30 00:00:0086.0086.1885.3785.6253695200.000.03
2025-07-31 00:00:0085.0285.5084.9485.2249814100.00-0.01
2025-08-31 00:00:0085.0385.2884.8785.1841686400.000.00
2025-09-30 00:00:0088.0888.7487.9288.3738584000.000.04
2025-10-31 00:00:0089.3089.6689.2189.5638247300.000.01

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

plot_timeseries(
    price_df=tlt,
    plot_start_date=start_date,
    plot_end_date=end_date,
    plot_columns=["Close"],
    title="TLT Close Price",
    x_label="Date",
    x_format="Year",
    y_label="Price ($)",
    y_format="Decimal",
    y_format_decimal_places=0,
    y_tick_spacing=10,
    grid=True,
    legend=False,
    export_plot=True,
    plot_file_name="03_TLT_Price",
)

TLT Price History

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

tlt_cycle_df = calc_fed_cycle_asset_performance(
    fed_cycles=fed_cycles,
    cycle_labels=cycle_labels,
    fed_changes=fed_changes,
    monthly_df=tlt_monthly,
)

Which gives us:

CycleStartEndMonthsCumulativeReturnCumulativeReturnPctAverageMonthlyReturnAverageMonthlyReturnPctAnnualizedReturnAnnualizedReturnPctVolatilityFedFundsChangeFedFundsChange_bpsFFR_AnnualizedChangeFFR_AnnualizedChange_bpsLabel
0Cycle 12004-11-012006-07-01200.044.230.000.250.032.510.090.03331.000.02198.60Cycle 1, 2004-11-01 to 2006-07-01
1Cycle 22006-07-012007-07-01120.065.760.000.490.065.760.070.002.000.002.00Cycle 2, 2006-07-01 to 2007-07-01
2Cycle 32007-07-012008-12-01170.3232.420.021.730.2221.920.14-0.05-510.00-0.04-360.00Cycle 3, 2007-07-01 to 2008-12-01
3Cycle 42008-12-012015-11-01830.4645.670.010.550.065.590.15-0.00-4.00-0.00-0.58Cycle 4, 2008-12-01 to 2015-11-01
4Cycle 52015-11-012019-01-01380.077.420.000.230.022.290.100.02228.000.0172.00Cycle 5, 2015-11-01 to 2019-01-01
5Cycle 62019-01-012019-07-0160.1010.480.021.730.2222.050.130.000.000.000.00Cycle 6, 2019-01-01 to 2019-07-01
6Cycle 72019-07-012020-04-0190.2626.180.032.730.3636.340.18-0.02-235.00-0.03-313.33Cycle 7, 2019-07-01 to 2020-04-01
7Cycle 82020-04-012022-02-0122-0.11-11.33-0.00-0.50-0.06-6.350.110.003.000.001.64Cycle 8, 2020-04-01 to 2022-02-01
8Cycle 92022-02-012023-08-0118-0.27-26.96-0.02-1.62-0.19-18.900.170.05525.000.03350.00Cycle 9, 2022-02-01 to 2023-08-01
9Cycle 102023-08-012024-08-0112-0.02-1.520.000.02-0.02-1.520.200.000.000.000.00Cycle 10, 2023-08-01 to 2024-08-01
10Cycle 112024-08-012026-01-26150.000.420.000.080.000.330.11-0.02-161.00-0.01-128.80Cycle 11, 2024-08-01 to 2026-01-26

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:

TLT Cumulative Returns

And then the annualized returns:

TLT Annualized Returns

Let’s focus our analysis on the plot comparing the annualized returns for TLT to the change in FFR. We can see that during cycles 3 and 7, the returns were very strong along with a rapid pace in cutting rates. During cycle 9, we see the opposite behavior, where as rates were increased the bond returns were very poor. The question for cycle 11, where bond returns have been essentially flat - is the pace of rate cuts not significant enough to benefit the bond market? Are there other factors at play that are influencing the long term bond returns? Keep in mind that we are also working with 20 year treasuries as well, but we could consider running analysis on investment grade or high yield corporate bonds.

Finally, we can run an OLS regression with the following code:

df = tlt_cycle_df

####################################
### 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

Which gives us the results of the OLS regression:

                             OLS Regression Results                            
===============================================================================
Dep. Variable:     AnnualizedReturnPct   R-squared:                       0.615
Model:                             OLS   Adj. R-squared:                  0.573
Method:                  Least Squares   F-statistic:                     14.39
Date:                 Mon, 26 Jan 2026   Prob (F-statistic):            0.00426
Time:                         12:06:37   Log-Likelihood:                -39.782
No. Observations:                   11   AIC:                             83.56
Df Residuals:                        9   BIC:                             84.36
Df Model:                            1                                         
Covariance Type:             nonrobust                                         
============================================================================================
                               coef    std err          t      P>|t|      [0.025      0.975]
--------------------------------------------------------------------------------------------
const                        5.4077      3.012      1.796      0.106      -1.405      12.221
FFR_AnnualizedChange_bps    -0.0591      0.016     -3.794      0.004      -0.094      -0.024
==============================================================================
Omnibus:                        0.635   Durbin-Watson:                   1.199
Prob(Omnibus):                  0.728   Jarque-Bera (JB):                0.621
Skew:                           0.387   Prob(JB):                        0.733
Kurtosis:                       2.131   Cond. No.                         194.
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.

And then plot the regression line along with the values:

plot_scatter_regression_ffr_vs_returns(
    cycle_df=tlt_cycle_df,
    asset_label="TLT",
    index_num="03",
    x_vals=X_vals,
    y_vals=Y_vals,
    intercept=model.params[0],
    slope=model.params[1],
)

Which gives us:

TLT Regression - Annualized Returns On Annualized Change In FFR

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.

Gold (GLD) #

Lastly, we’ll look at the returns on gold, using the GLD ETF as a proxy.

First, we pull data with the following:

# Set decimal places
pandas_set_decimal_places(2)

yf_pull_data(
    base_directory=DATA_DIR,
    ticker="GLD",
    source="Yahoo_Finance", 
    asset_class="Exchange_Traded_Funds", 
    excel_export=True,
    pickle_export=True,
    output_confirmation=True,
)

And then load data with the following:

gld = load_data(
    base_directory=DATA_DIR,
    ticker="GLD",
    source="Yahoo_Finance", 
    asset_class="Exchange_Traded_Funds",
    timeframe="Daily",
    file_format="pickle",
)

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

# Resample to monthly frequency
gld_monthly = gld.resample("M").last()
gld_monthly["Monthly_Return"] = gld_monthly["Close"].pct_change()

Gives us the following:

The columns, shape, and data types are:

<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 252 entries, 2004-11-30 to 2025-10-31
Freq: ME
Data columns (total 6 columns):
 #   Column          Non-Null Count  Dtype  
---  ------          --------------  -----  
 0   Close           252 non-null    float64
 1   High            252 non-null    float64
 2   Low             252 non-null    float64
 3   Open            252 non-null    float64
 4   Volume          252 non-null    int64  
 5   Monthly_Return  251 non-null    float64
dtypes: float64(5), int64(1)
memory usage: 13.8 KB

The first 5 rows are:

DateCloseHighLowOpenVolumeMonthly_Return
2004-11-30 00:00:0045.1245.4144.8245.373857200.00nan
2004-12-31 00:00:0043.8043.9443.7343.85531600.00-0.03
2005-01-31 00:00:0042.2242.3041.9642.211692400.00-0.04
2005-02-28 00:00:0043.5343.7443.5243.68755300.000.03
2005-03-31 00:00:0042.8242.8742.7042.871363200.00-0.02

The last 5 rows are:

DateCloseHighLowOpenVolumeMonthly_Return
2025-06-30 00:00:00304.83304.92301.95302.398192100.000.00
2025-07-31 00:00:00302.96304.61302.86304.598981000.00-0.01
2025-08-31 00:00:00318.07318.09314.64314.7215642600.000.05
2025-09-30 00:00:00355.47355.57350.87351.1313312400.000.12
2025-10-31 00:00:00368.12370.66365.50370.4711077900.000.04

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

plot_timeseries(
    price_df=gld,
    plot_start_date=start_date,
    plot_end_date=end_date,
    plot_columns=["Close"],
    title="GLD Close Price",
    x_label="Date",
    x_format="Year",
    y_label="Price ($)",
    y_format="Decimal",
    y_format_decimal_places=0,
    y_tick_spacing=25,
    grid=True,
    legend=False,
    export_plot=True,
    plot_file_name="04_GLD_Price",
)

GLD Price History

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

gld_cycle_df = calc_fed_cycle_asset_performance(
    fed_cycles=fed_cycles,
    cycle_labels=cycle_labels,
    fed_changes=fed_changes,
    monthly_df=gld_monthly,
)

Which gives us:

CycleStartEndMonthsCumulativeReturnCumulativeReturnPctAverageMonthlyReturnAverageMonthlyReturnPctAnnualizedReturnAnnualizedReturnPctVolatilityFedFundsChangeFedFundsChange_bpsFFR_AnnualizedChangeFFR_AnnualizedChange_bpsLabel
0Cycle 12004-11-012006-07-01200.3635.700.021.730.2020.100.170.03331.000.02198.60Cycle 1, 2004-11-01 to 2006-07-01
1Cycle 22006-07-012007-07-01120.054.960.000.450.054.960.110.002.000.002.00Cycle 2, 2006-07-01 to 2007-07-01
2Cycle 32007-07-012008-12-01170.2524.960.021.590.1717.030.26-0.05-510.00-0.04-360.00Cycle 3, 2007-07-01 to 2008-12-01
3Cycle 42008-12-012015-11-01830.3636.100.010.510.054.560.18-0.00-4.00-0.00-0.58Cycle 4, 2008-12-01 to 2015-11-01
4Cycle 52015-11-012019-01-01380.1110.930.000.350.033.330.140.02228.000.0172.00Cycle 5, 2015-11-01 to 2019-01-01
5Cycle 62019-01-012019-07-0160.109.860.021.630.2120.680.120.000.000.000.00Cycle 6, 2019-01-01 to 2019-07-01
6Cycle 72019-07-012020-04-0190.1111.150.011.240.1515.130.13-0.02-235.00-0.03-313.33Cycle 7, 2019-07-01 to 2020-04-01
7Cycle 82020-04-012022-02-01220.1413.540.010.690.077.170.160.003.000.001.64Cycle 8, 2020-04-01 to 2022-02-01
8Cycle 92022-02-012023-08-01180.088.480.010.530.065.580.140.05525.000.03350.00Cycle 9, 2022-02-01 to 2023-08-01
9Cycle 102023-08-012024-08-01120.2424.240.021.890.2424.240.130.000.000.000.00Cycle 10, 2023-08-01 to 2024-08-01
10Cycle 112024-08-012026-01-26150.6262.490.033.360.4747.460.14-0.02-161.00-0.01-128.80Cycle 11, 2024-08-01 to 2026-01-26

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:

GLD Cumulative Returns

And then the annualized returns:

GLD Annualized Returns

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 with the following code:

df = gld_cycle_df

####################################
### 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

Which gives us the results of the OLS regression:

                             OLS Regression Results                            
===============================================================================
Dep. Variable:     AnnualizedReturnPct   R-squared:                       0.093
Model:                             OLS   Adj. R-squared:                 -0.008
Method:                  Least Squares   F-statistic:                    0.9214
Date:                 Mon, 26 Jan 2026   Prob (F-statistic):              0.362
Time:                         12:06:40   Log-Likelihood:                -42.778
No. Observations:                   11   AIC:                             89.56
Df Residuals:                        9   BIC:                             90.35
Df Model:                            1                                         
Covariance Type:             nonrobust                                         
============================================================================================
                               coef    std err          t      P>|t|      [0.025      0.975]
--------------------------------------------------------------------------------------------
const                       15.1586      3.955      3.833      0.004       6.213      24.104
FFR_AnnualizedChange_bps    -0.0196      0.020     -0.960      0.362      -0.066       0.027
==============================================================================
Omnibus:                        7.682   Durbin-Watson:                   0.913
Prob(Omnibus):                  0.021   Jarque-Bera (JB):                3.504
Skew:                           1.305   Prob(JB):                        0.173
Kurtosis:                       3.912   Cond. No.                         194.
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.

And then plot the regression line along with the values:

plot_scatter_regression_ffr_vs_returns(
    cycle_df=gld_cycle_df,
    asset_label="GLD",
    index_num="04",
    x_vals=X_vals,
    y_vals=Y_vals,
    intercept=model.params[0],
    slope=model.params[1],
)

Which gives us:

GLD Regression - Annualized Returns On Annualized Change In FFR

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.

Hybrid Portfolio #

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.

Recall the plots for annualized returns vs annualized change in FFR for stocks, bonds, and gold:

SPY Annualized Returns TLT Annualized Returns GLD Annualized Returns

Asset Allocation #

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, and then hold bonds 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. That gives us:

  • Cycle 1: Stocks
  • Cycle 2: Stocks
  • Cycle 3: Bonds
  • Cycle 4: Stocks
  • Cycle 5: Stocks
  • Cycle 6: Stocks
  • Cycle 7: Bonds
  • Cycle 8: Stocks
  • Cycle 9: Stocks
  • Cycle 10: Stocks
  • Cycle 11: Bonds

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

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

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

# Isolate the returns for SPY and TLT
spy_ret = spy_monthly['Monthly_Return']
tlt_ret = tlt_monthly['Monthly_Return']

# Create a blended portfolio based on Fed policy cycles
portfolio = (
    spy_ret[spy_ret.index <= "2007-07-01"]
    .combine_first(tlt_ret[(tlt_ret.index >= "2007-07-01") & (tlt_ret.index <= "2008-12-01")])
    .combine_first(spy_ret[(spy_ret.index > "2008-12-01") & (spy_ret.index <= "2019-07-01")])
    .combine_first(tlt_ret[(tlt_ret.index >= "2019-07-01") & (tlt_ret.index <= "2020-04-01")])
    .combine_first(spy_ret[(spy_ret.index > "2020-04-01") & (spy_ret.index <= "2024-08-01")])
    .combine_first(tlt_ret[tlt_ret.index > "2024-08-01"])
)

# Convert to DataFrame
portfolio_monthly = portfolio.to_frame(name="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 "spy_monthly" and "tlt_monthly" into "portfolio_monthly" to compare cumulative returns
portfolio_monthly = portfolio_monthly.join(
    spy_monthly['Monthly_Return'].rename('SPY_Monthly_Return'),
    how='left'
).join(
    spy_monthly['Cumulative_Return'].rename('SPY_Cumulative_Return'),
    how='left'
).join(
    spy_monthly['Drawdown'].rename('SPY_Drawdown'),
    how='left'
).join(
    tlt_monthly['Monthly_Return'].rename('TLT_Monthly_Return'),
    how='left'
).join(
    tlt_monthly['Cumulative_Return'].rename('TLT_Cumulative_Return'),
    how='left'
).join(
    tlt_monthly['Drawdown'].rename('TLT_Drawdown'),
    how='left'
)

Which gives us:

The columns, shape, and data types are:

<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 252 entries, 2004-11-30 to 2025-10-31
Freq: ME
Data columns (total 9 columns):
 #   Column                       Non-Null Count  Dtype  
---  ------                       --------------  -----  
 0   Portfolio_Monthly_Return     251 non-null    float64
 1   Portfolio_Cumulative_Return  251 non-null    float64
 2   Portfolio_Drawdown           251 non-null    float64
 3   SPY_Monthly_Return           251 non-null    float64
 4   SPY_Cumulative_Return        251 non-null    float64
 5   SPY_Drawdown                 251 non-null    float64
 6   TLT_Monthly_Return           251 non-null    float64
 7   TLT_Cumulative_Return        251 non-null    float64
 8   TLT_Drawdown                 251 non-null    float64
dtypes: float64(9)
memory usage: 19.7 KB

The first 5 rows are:

DatePortfolio_Monthly_ReturnPortfolio_Cumulative_ReturnPortfolio_DrawdownSPY_Monthly_ReturnSPY_Cumulative_ReturnSPY_DrawdownTLT_Monthly_ReturnTLT_Cumulative_ReturnTLT_Drawdown
2004-11-30 00:00:00nannannannannannannannannan
2004-12-31 00:00:000.0300.0300.0000.0300.0300.0000.0270.0270.000
2005-01-31 00:00:00-0.0220.007-0.022-0.0220.007-0.0220.0360.0630.000
2005-02-28 00:00:000.0210.028-0.0020.0210.028-0.002-0.0150.048-0.015
2005-03-31 00:00:00-0.0180.009-0.020-0.0180.009-0.020-0.0050.043-0.019

The last 5 rows are:

DatePortfolio_Monthly_ReturnPortfolio_Cumulative_ReturnPortfolio_DrawdownSPY_Monthly_ReturnSPY_Cumulative_ReturnSPY_DrawdownTLT_Monthly_ReturnTLT_Cumulative_ReturnTLT_Drawdown
2025-06-30 00:00:000.02719.004-0.0720.0516.7180.0000.0270.963-0.408
2025-07-31 00:00:00-0.01118.776-0.0820.0236.8960.000-0.0110.941-0.415
2025-08-31 00:00:000.00018.778-0.0820.0217.0580.0000.0000.941-0.415
2025-09-30 00:00:000.03619.489-0.0490.0367.3450.0000.0361.011-0.394
2025-10-31 00:00:000.01419.772-0.0360.0247.5440.0000.0141.039-0.385

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

Performance Statistics #

We can then plot the monthly returns:

Monthly Returns

And cumulative returns:

Cumulative Returns

And drawdowns:

Drawdowns

Finally, we can run the stats on the hybrid portfolio, SPY, and TLT with the following code:

port_sum_stats = summary_stats(
    fund_list=["Portfolio", "SPY", "TLT"],
    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", "SPY", "TLT"],
    df=portfolio_monthly[["SPY_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", "SPY", "TLT"],
    df=portfolio_monthly[["TLT_Monthly_Return"]],
    period="Monthly",
    use_calendar_days=False,
    excel_export=False,
    pickle_export=False,
    output_confirmation=False,
)

sum_stats = port_sum_stats.combine_first(spy_sum_stats).combine_first(tlt_sum_stats)

Which gives us:

Annualized MeanAnnualized VolatilityAnnualized Sharpe RatioCAGRMonthly Max ReturnMonthly Max Return (Date)Monthly Min ReturnMonthly Min Return (Date)Max DrawdownPeakTroughRecovery DateDays to RecoverMAR Ratio
Portfolio_Monthly_Return0.1560.1401.1110.1550.1432008-11-30 00:00:00-0.1072009-02-28 00:00:00-0.2392021-12-31 00:00:002022-09-30 00:00:002023-12-31 00:00:00457.0000.650
SPY_Monthly_Return0.1140.1480.7690.1080.1272020-04-30 00:00:00-0.1652008-10-31 00:00:00-0.5082007-10-31 00:00:002009-02-28 00:00:002012-03-31 00:00:001127.0000.212
TLT_Monthly_Return0.0430.1370.3160.0350.1432008-11-30 00:00:00-0.1312009-01-31 00:00:00-0.4762020-07-31 00:00:002023-10-31 00:00:00NaTnan0.072

Based on the above, our hybrid portfolio outperforms both stocks and bonds, and by a wide margin.

Future Investigation #

A couple of ideas sound intriguing for future investigation:

  • Do investment grade or high yield bonds show a different behavior than the long term US treasury bonds?
  • Does a commodity index (such as GSCI) exhibit differing behavior than gold?
  • How does leverage affect the returns that are observed for the hybrid portfolio, stocks, and bonds?
  • Do other Fed tightening/loosening cycles exhibit the same behavior for returns?

References #

  1. https://fred.stlouisfed.org/series/FEDFUNDS

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.