Featured image of post Performance Of Various Asset Classes During Fed Policy Cycles

Performance Of Various Asset Classes During Fed Policy Cycles

How does the performance of stocks, bonds, and other asset classes vary during Fed policy cycles?

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 the performance of various asset classes during the Fed Funds cycles.
  • 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_timeseries: Plot the timeseries data from a DataFrame for a specified date range and columns.
  • 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.
  • 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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 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
})
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.0124

Return Performance By Fed Policy Cycle

Moving on, we will now look at the performance of three (3) 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.

Stocks (SPY)

First, we pull data with the following:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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()

Gives us the following:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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.8380.0779.6679.9053685200.00nan
2004-12-31 00:00:0082.2382.7782.1982.5328648800.000.03
2005-01-31 00:00:0080.3980.4580.0880.2552532700.00-0.02
2005-02-28 00:00:0082.0782.5381.6782.4369381300.000.02
2005-03-31 00:00:0080.5780.9180.5180.7364575400.00-0.02

The last 5 rows are:

DateCloseHighLowOpenVolumeMonthly_Return
2025-06-30 00:00:00616.14617.51613.34615.6792502500.000.05
2025-07-31 00:00:00630.33638.08629.03637.69103385200.000.02
2025-08-31 00:00:00643.27646.05641.36645.6874522200.000.02
2025-09-30 00:00:00666.18666.65661.61662.9386288000.000.04
2025-10-31 00:00:00682.06685.08679.24685.0487164100.000.02

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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:

1
2
3
4
5
6
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-012025-11-30150.2625.720.021.590.2020.090.11-0.01-124.00-0.01-99.20Cycle 11, 2024-08-01 to 2025-11-30

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:

SPY Cumulative Returns

And then the annualized returns:

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), the stocks have exhibited negative returns during the rate cutting cycle. However, after the rate cutting cycle was complete, the following returns 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, the stock market falls, the returns become negative, and the Fed responds with cutting rates.

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
                             OLS Regression Results                            
===============================================================================
Dep. Variable:     AnnualizedReturnPct   R-squared:                       0.184
Model:                             OLS   Adj. R-squared:                  0.093
Method:                  Least Squares   F-statistic:                     2.031
Date:                 Sun, 30 Nov 2025   Prob (F-statistic):              0.188
Time:                         07:50:16   Log-Likelihood:                -47.144
No. Observations:                   11   AIC:                             98.29
Df Residuals:                        9   BIC:                             99.08
Df Model:                            1                                         
Covariance Type:             nonrobust                                         
============================================================================================
                               coef    std err          t      P>|t|      [0.025      0.975]
--------------------------------------------------------------------------------------------
const                       12.3840      5.875      2.108      0.064      -0.907      25.675
FFR_AnnualizedChange_bps     0.0437      0.031      1.425      0.188      -0.026       0.113
==============================================================================
Omnibus:                        1.011   Durbin-Watson:                   3.089
Prob(Omnibus):                  0.603   Jarque-Bera (JB):                0.652
Skew:                           0.032   Prob(JB):                        0.722
Kurtosis:                       1.809   Cond. No.                         192.
==============================================================================

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:

1
2
3
4
5
6
7
8
9
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

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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:0044.1344.2443.9744.131754500.00nan
2004-12-31 00:00:0045.3045.3545.1745.211056400.000.03
2005-01-31 00:00:0046.9246.9446.7046.721313900.000.04
2005-02-28 00:00:0046.2246.7846.1646.782797300.00-0.01
2005-03-31 00:00:0046.0146.0545.7745.952410900.00-0.00

The last 5 rows are:

DateCloseHighLowOpenVolumeMonthly_Return
2025-06-30 00:00:0086.6486.8386.0186.2653695200.000.03
2025-07-31 00:00:0085.6586.1485.5785.8649814100.00-0.01
2025-08-31 00:00:0085.6685.9285.5185.8241686400.000.00
2025-09-30 00:00:0088.7489.4088.5889.0338584000.000.04
2025-10-31 00:00:0089.9690.3389.8890.2338247300.000.01

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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:

1
2
3
4
5
6
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-012025-11-30150.000.420.000.080.000.330.11-0.01-124.00-0.01-99.20Cycle 11, 2024-08-01 to 2025-11-30

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
                             OLS Regression Results                            
===============================================================================
Dep. Variable:     AnnualizedReturnPct   R-squared:                       0.634
Model:                             OLS   Adj. R-squared:                  0.593
Method:                  Least Squares   F-statistic:                     15.56
Date:                 Sun, 30 Nov 2025   Prob (F-statistic):            0.00338
Time:                         07:50:20   Log-Likelihood:                -39.515
No. Observations:                   11   AIC:                             83.03
Df Residuals:                        9   BIC:                             83.83
Df Model:                            1                                         
Covariance Type:             nonrobust                                         
============================================================================================
                               coef    std err          t      P>|t|      [0.025      0.975]
--------------------------------------------------------------------------------------------
const                        5.5490      2.937      1.890      0.091      -1.094      12.192
FFR_AnnualizedChange_bps    -0.0604      0.015     -3.944      0.003      -0.095      -0.026
==============================================================================
Omnibus:                        0.797   Durbin-Watson:                   1.248
Prob(Omnibus):                  0.671   Jarque-Bera (JB):                0.712
Skew:                           0.441   Prob(JB):                        0.701
Kurtosis:                       2.121   Cond. No.                         192.
==============================================================================

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:

1
2
3
4
5
6
7
8
9
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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:

1
2
3
4
5
6
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-012025-11-30150.6262.490.033.360.4747.460.14-0.01-124.00-0.01-99.20Cycle 11, 2024-08-01 to 2025-11-30

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
                             OLS Regression Results                            
===============================================================================
Dep. Variable:     AnnualizedReturnPct   R-squared:                       0.073
Model:                             OLS   Adj. R-squared:                 -0.030
Method:                  Least Squares   F-statistic:                    0.7118
Date:                 Sun, 30 Nov 2025   Prob (F-statistic):              0.421
Time:                         07:50:25   Log-Likelihood:                -42.895
No. Observations:                   11   AIC:                             89.79
Df Residuals:                        9   BIC:                             90.59
Df Model:                            1                                         
Covariance Type:             nonrobust                                         
============================================================================================
                               coef    std err          t      P>|t|      [0.025      0.975]
--------------------------------------------------------------------------------------------
const                       15.2394      3.993      3.817      0.004       6.207      24.272
FFR_AnnualizedChange_bps    -0.0176      0.021     -0.844      0.421      -0.065       0.030
==============================================================================
Omnibus:                        8.464   Durbin-Watson:                   0.918
Prob(Omnibus):                  0.015   Jarque-Bera (JB):                3.915
Skew:                           1.356   Prob(JB):                        0.141
Kurtosis:                       4.091   Cond. No.                         192.
==============================================================================

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:

1
2
3
4
5
6
7
8
9
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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.

Licensed under CC BY-NC-SA 4.0
Built with Hugo
Theme Stack designed by Jimmy