Back To Top

August 14, 2025

Detecting Stock Market Cycles

Determining early, mid-late, decline, and recovery regimes using a composite of FRED indicators and S&P 500 trends in Python.

Market cycles push asset prices through asset prices through phases of expansion, contraction, and uncertainty.

These regimes shape both risk and return. But cycles don’t announce themselves in real time, and the volatility doesn’t move in isolation.

The challenge is that market shifts happen as growth, inflation, credit, and sentiment change on their own timing and signal-to-noise ratio.

However, in this article, we present an analytical approach to market cycle detection using a composite of macroeconomic and financial indicators.

We combine leading, coincident, and risk-based measures from the FRED database with observed S&P 500 returns. The result is the plot below.

The complete Python notebook for the analysis is provided below.

Detecting Stock Market Cycles

1. The Importance of Market Cycle Detection

Market cycles shape the environment for risk and return, which is critical for effective asset allocation.

Patterns in growth, inflation, credit, and sentiment drive asset prices, but each factor moves on its own schedule.

Please note that market cycle awareness is not about prediction or market timing, it’s about adapting exposures to the current environment.

As Howard Marks notes, “You can’t predict. You can prepare”.

Cycle recognition allows investors to systematically tilt toward or away from risk, growth, or defensiveness, without relying on forecasts.

Empirical Evidence

Academic research consistently finds that asset returns, drawdown risk, and diversification benefits are strongly conditional on the business and credit cycle.

For example, Gospodinov et al. (2021) show that most excess equity returns occur during early and mid-expansion phases, while drawdowns concentrate during contraction and late-cycle.

Institutional Application

Multi-asset strategies, from pension funds to macro hedge funds, explicitly embed regime frameworks to manage portfolio beta, sector tilts, and hedging activity.

The Bank for International Settlements highlights that “market phase classification is central to financial stability analysis and macroprudential policy”.

2. Building a Market Cycle Composite Indicator

Our composite indicator aggregates information from multiple sources to create a single, interpretable signal.

We leverage the strengths of diverse macroeconomic and financial variables and minimize the influence of noisy behavior in any one series.

2.1 Rationale for Using Multiple Inputs

Please note that no single economic or market indicator provides reliable classification of market cycles in real-time.

For example, growth data may signal improvement while credit spreads warn of rising risk. Inflation and stress may move on different timeframes.

Therefore, we need to integrate a broad set of indicators to cover growth, inflation, credit, and market stress.

2.2 Indicator Selection

Variables are chosen not only for their economic relevance in capturing market cycles, but also for their accessibility as public data sources:

  • LEI (Leading Economic Index): Composite of forward-looking indicators. Used as a broad proxy for future economic activity.
  • Philly Manufacturing Diffusion Index: Measures regional manufacturing strength. Early signal of industrial cycle turns.
  • Texas Services Diffusion Index: Measures regional services activity. Used as a proxy for broader U.S. services sector momentum.
  • Capacity Utilization: Indicates resource use in manufacturing. Declining utilization often precedes recessions.
  • Brave-Butters-Kelley (BBK) Leading Index: Proprietary indicator that anticipates turning points in U.S. economic growth. It combines real-time business, labor, and financial data.
  • CFNAI 3-Month Moving Average: Measures U.S. output, employment, consumption, and sales, smoothing monthly noise.
  • Core CPI, Core PCE, Hourly Wage, PPI, Commodities: These five form an inflation composite. Core CPI and Core PCE measure underlying inflation. Hourly wage growth captures labor cost pressure. PPI represents input cost trends. Commodities index reflects global price changes.
  • 10-Year Treasury Yield (12m delta, inverted): Captures changes in long-term interest rates. Inversion aligns higher yields with restrictive conditions.
  • High Yield OAS (12m delta, inverted): Measures corporate credit spreads as a risk sentiment indicator. Inverted so wider spreads lower the composite.
  • St. Louis Fed Financial Stress Index (inverted): Measures systemic market stress. Inverted so increased stress lowers the composite.

2.3 Data Transformation

All series are aligned to a monthly frequency. Most indicators are converted to year-over-year percent change:

Detecting Stock Market Cycles

Credit spreads and interest rates use a 12-month difference, inverted where appropriate so that higher values reflect tightening or stress:

Detecting Stock Market Cycles

The Inflation Composite is the average of the five inflation series:

Detecting Stock Market Cycles

2.4 Data Standardization

To make the signals comparable, each variable is standardized to have zero mean and unit variance (z-score):

Detecting Stock Market Cycles

Xi is the transformed value for variable at time t, μ is the historical mean, and σ is the standard deviation.

2.5 Composite Score Construction

At each point in time, the composite score is the simple average of all standardized inputs:

Detecting Stock Market Cycles

N is the total number of inputs (after aggregating inflation).

To reduce short-term noise, a moving average is applied to Ct:

Detecting Stock Market Cycles

w is the smoothing window (we use 2 months).

2.6 Composite Score Construction

The direction of change in the composite is captured using a rolling difference:

Detecting Stock Market Cycles

Ct is the current composite score and w is the slope window (e.g. 3 months).

The slope measures whether conditions are improving or deteriorating over a short look-back window.

2.7 Cycle Classification

Regime assignment uses both the level and slope of the composite to distinguish among four market phases:

  • Early: Composite is significantly below neutral, but momentum has turned positive
  • Decline: Composite is significantly below neutral, and momentum is negative
  • Mid-Late: Composite is well above neutral
  • Uncertain: Composite is near neutral, indicating mixed signals

Formally, regimes are classified as:

Detecting Stock Market Cycles

γ is the composite level threshold and δ is the slope threshold.

3. Implementation in Python

3.1 Key Parameters

Parameter choices shape the sensitivity of the composite.

We set the sample to start in 2000 to capture the major crashes of the last two decades.

We use monthly frequency to balance signal stability with timely detection.

The short smoothing and slope windows allow our model to react to new economic shifts without overreacting to transient noise.

				
					import pandas as pd
import numpy as np
import pandas_datareader.data as web
import yfinance as yf
import matplotlib.pyplot as plt
from matplotlib.patches import Patch
from datetime import datetime
import matplotlib.dates as mdates
import math
plt.style.use('dark_background')

# PARAMETERS
START, END    = '2000-01-01', datetime.today().strftime('%Y-%m-%d')
FREQ          = 'ME'       # Pandas resample frequency for data (e.g. 'ME'=month‑end); controls aggregation period—higher freq ('D') adds noise, lower ('Q') smooths
SLOPE_WINDOW  = 3          # Look‑back periods for slope = comp.diff(SLOPE_WINDOW); larger values smooth and delay turn signals, smaller values speed detection but add volatility
SMOOTH_WINDOW = 2          # Window for composite moving average before plotting; larger windows yield a smoother but more lagged chart, smaller windows follow raw swings closely
				
			

3.1 Retrieve Data Inputs

Each indicator is retrieved from FRED using its public ticker.

Later, we’ll have to standardize these variables for a sensible comparison.

				
					# SERIES MAP
fred_map = {
    # growth / activity
    'LEI'               : 'USSLIND',           # Leading Index for US (Philly Fed state leading), proxy for overall LEI
    'Philly Manuf Diff' : 'GACDFSA066MSFRBPHI',# Philly Fed manufacturing diffusion index, proxy for ISM Mfg PMI (ISM is no longer supported at Fred)
    'Texas Serv Diff'   : 'TSSOSBACTUAMFRBDAL',# Dallas Fed services diffusion index, proxy for ISM Services PMI (ISM is no longer supported at Fred)
    'Capacity Util'     : 'CUMFNS',            # Industrial capacity utilization rate
    'BBK Leading'       : 'BBKMLEIX',          # Brave‑Butters‑Kelley leading index, leading growth component
    'CFNAI 3MMA'        : 'CFNAIMA3',          # Chicago Fed National Financial Activity Index 3‑mo avg

    # inflation sub‑block
    'Core CPI'          : 'CPILFESL',          # Core CPI ex‑food & energy, inflation proxy
    'Core PCE'          : 'PCEPILFE',          # Core PCE inflation proxy
    'Hourly Wage'       : 'CES0500000003',     # Avg hourly earnings YoY (BLS)
    'PPI'               : 'PPIACO',            # Producer Price Index commodities YoY
    'Commodities'       : 'PALLFNFINDEXM',     # BCOM commodity price index YoY

    # rates & credit
    '10Y'               : 'DGS10',             # 10‑year Treasury yield (Δ12m)
    'HY OAS'            : 'BAMLH0A0HYM2',      # High‑yield OAS (Δ12m, inverted)

    # stress proxy
    'StLouis FSI'       : 'STLFSI4',           # St. Louis Fed Financial Stress Index (inverted)
}

# FETCH & RESAMPLE
raw = {k: web.DataReader(tkr, 'fred', START, END).squeeze()
       for k, tkr in fred_map.items()}
df  = pd.DataFrame(raw).resample(FREQ).last().ffill()
				
			

3.2 Plot All Input Series for Composite Score

We now visualize the raw input series to make sure there are no obvious abnormalities.

				
					# PLOT ALL SERIES
df_raw = df.copy()

# Determine grid size
n_series = len(df_raw.columns)
ncols    = 3
nrows    = math.ceil(n_series / ncols)

# sharex=False ⇒ each axis shows its own ticks
fig, axes = plt.subplots(
    nrows, ncols,
    figsize=(ncols*6, nrows*2.5),
    sharex=False
)

# Flatten axes for easy iteration
axes_flat = axes.flatten()

xmin, xmax = df_raw.index.min(), df_raw.index.max()

for ax, col in zip(axes_flat, df_raw.columns):
    ax.plot(df_raw.index, df_raw[col], lw=1, color='white')
    ax.set_title(col, fontsize=9)
    # enforce same x‐limits on each
    ax.set_xlim(xmin, xmax)
    # format each x‐axis independently
    ax.xaxis.set_major_locator(mdates.YearLocator(1))
    ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y'))
    ax.tick_params(axis='x', rotation=45, labelsize=7)
    ax.tick_params(axis='y', labelsize=7)

# Turn off any unused subplots
for ax in axes_flat[n_series:]:
    ax.set_visible(False)

plt.tight_layout()
plt.show()
				
			
Figure 1. Raw time series for all macroeconomic and market indicators used in the Market-Cycle Composite.

Figure 1. Raw time series for all macroeconomic and market indicators used in the Market-Cycle Composite.

3.3 Data Transformation

Each series is transformed to focus on changes, not levels.

Year-over-year percent change is used for most variables. Credit spreads and interest rates use 12-month differences, and risk proxies are inverted.

Prev Post

Visualizing Market Pressure of Buyers and Sellers

Next Post

When Market Intuition Beats Algorithms

post-bars
Mail Icon

Newsletter

Get Every Weekly Update & Insights

[mc4wp_form id=]

Leave a Comment