Federal Reserve Liquidity Composite
Abstract
This document formalises a Fed liquidity composite built from four inputs: money supply (M2SL), Federal Reserve total assets (WALCL), reserve balances (WRESBAL), and overnight reverse repo usage (RRPONTSYD). Each series is aligned to month‑end, transformed into year‑over‑year and 3‑month percentage changes, and classified via symmetric thresholds. A z‑score blend across inputs yields a continuous composite that is discretised into Expansion, Neutral, or Contraction regimes. ON RRP is treated as a tightening gauge (falls are easing).
1. Data
- M2SL — M2 Money Stock (monthly, FRED).
- WALCL — Total Assets of the Federal Reserve (weekly; aggregated to month‑end).
- WRESBAL — Reserve Balances with Federal Reserve Banks (weekly; aggregated to month‑end).
- RRPONTSYD — ON Reverse Repo usage, Treasury collateral (daily; aggregated to month‑end).
All inputs are trimmed to a rolling window of the most recent N years (N = 3 by default) using the latest available date per series to ensure focus on current regime dynamics.
2. Alignment & Transformations
- Month‑end alignment: resample each series to month‑end and select the last observation for that month.
- Rate of change: compute percentage changes for YoY and 3‑month windows:
X_{YoY,t} = 100 × (X_t − X_{t-12}) / X_{t-12}X_{3M,t} = 100 × (X_t − X_{t-3}) / X_{t-3}
- Sign convention for ON RRP: interpret declines as easing; when forming signals and the composite, use −RRP_{YoY} and −RRP_{3M}.
3. Signal Classification
Each input is mapped to categorical Bullish/Neutral/Bearish using symmetric thresholds on level (YoY) and impulse (3M):
- M2: high_yoy=6.0, low_yoy=2.0, accel_3m=1.0, decel_3m=−1.0
- Fed Assets (WALCL): high_yoy=5.0, low_yoy=0.0, accel_3m=0.8, decel_3m=−0.5
- Reserves (WRESBAL): high_yoy=8.0, low_yoy=1.0, accel_3m=1.5, decel_3m=−0.8
- ON RRP (inverted): high_yoy=−5.0, low_yoy=0.0, accel_3m=−1.0, decel_3m=0.5
Rule: if YoY > high_yoy or 3M > accel_3m ⇒ Bullish; if YoY < low_yoy or 3M < decel_3m ⇒ Bearish; else Neutral. Missing inputs default to Neutral.
4. Composite Construction
- Standardise: form z‑scores of YoY and 3M for each input (population standard deviation):
z(s) = (s − μ(s)) / σ(s)
- Per‑input score: sum level and impulse components (e.g., z(M2_{YoY}) + z(M2_{3M})).
- Liquidity Composite: arithmetic mean of the four per‑input scores:
LC_t = mean( z(M2_{YoY})+z(M2_{3M}), z(FED_{YoY})+z(FED_{3M}), z(RES_{YoY})+z(RES_{3M}), z(−RRP_{YoY})+z(−RRP_{3M}) )
- Regime mapping: discretise using fixed cut‑offs:
LC ≤ −0.75 ⇒ Contraction; |LC| < 0.75 ⇒ Neutral; LC ≥ 0.75 ⇒ Expansion
5. Implementation Notes (Python)
# Window: last N years
years_back = 3
def _keep_last_n_years(df, years):
if df is None or df.empty: return df
df = df.copy(); df['Date'] = pd.to_datetime(df['Date'], errors='coerce')
max_date = df['Date'].max()
if pd.isna(max_date): return df
cutoff = max_date - pd.DateOffset(years=years)
return df.loc[df['Date'] >= cutoff].reset_index(drop=True)
# Align to month-end and compute changes
def align_to_month_end(df, date_col='Date', value_cols=['Value']):
dfa = df.copy(); dfa[date_col] = pd.to_datetime(dfa[date_col])
dfa = dfa.set_index(date_col).sort_index()
dfa = dfa[value_cols].resample('M').last().reset_index().rename(columns={'index':'Date'})
return dfa
import numpy as np
def pct_change(s, periods):
out = s.pct_change(periods=periods) * 100
return out.replace([np.inf, -np.inf], np.nan)
def add_change_columns(df, value_col='Value', prefix='X'):
df = df.copy()
df[f'{prefix}_YoY'] = pct_change(df[value_col], 12)
df[f'{prefix}_3M'] = pct_change(df[value_col], 3)
return df
def classify_signal(yoy, m3, high_yoy, low_yoy, accel_3m, decel_3m):
if pd.isna(yoy) or pd.isna(m3): return 'Neutral'
if (yoy > high_yoy) or (m3 > accel_3m): return 'Bullish'
if (yoy < low_yoy) or (m3 < decel_3m): return 'Bearish'
return 'Neutral'
def zscore(s):
return (s - s.mean()) / s.std(ddof=0)
# After fetching: m2sl_df, fed_assets_df (WALCL), reserves_df (WRESBAL), on_rrp_df (RRPONTSYD)
m2_aligned = align_to_month_end(m2sl_df, 'Date', ['Value'])
fed_assets_al = align_to_month_end(fed_assets_df, 'Date', ['Value'])
reserves_al = align_to_month_end(reserves_df, 'Date', ['Value'])
on_rrp_al = align_to_month_end(on_rrp_df, 'Date', ['Value'])
m2_aligned = add_change_columns(m2_aligned, 'Value', 'M2')
fed_assets_al = add_change_columns(fed_assets_al, 'Value', 'FED')
reserves_al = add_change_columns(reserves_al, 'Value', 'RES')
on_rrp_al = add_change_columns(on_rrp_al, 'Value', 'RRP')
# Merge panel (last years_back years)
base = m2_aligned[['Date','Value','M2_YoY','M2_3M']].rename(columns={'Value':'M2_Value'})
base = base.merge(fed_assets_al.rename(columns={'Value':'FED_Value'}), on='Date', how='outer')
base = base.merge(reserves_al.rename(columns={'Value':'RES_Value'}), on='Date', how='outer')
base = base.merge(on_rrp_al.rename(columns={'Value':'RRP_Value'}), on='Date', how='outer')
base = base.sort_values('Date').reset_index(drop=True)
cutoff = pd.to_datetime(base['Date'].max()) - pd.DateOffset(years=years_back)
base = base.loc[base['Date'] >= cutoff].reset_index(drop=True)
# Classify each input (note inverted RRP)
M2_THR = dict(high_yoy=6.0, low_yoy=2.0, accel_3m=1.0, decel_3m=-1.0)
FED_THR = dict(high_yoy=5.0, low_yoy=0.0, accel_3m=0.8, decel_3m=-0.5)
RES_THR = dict(high_yoy=8.0, low_yoy=1.0, accel_3m=1.5, decel_3m=-0.8)
RRP_THR = dict(high_yoy=-5.0, low_yoy=0.0, accel_3m=-1.0, decel_3m=0.5)
base['M2_Signal'] = base.apply(lambda r: classify_signal(r['M2_YoY'], r['M2_3M'], **M2_THR), axis=1)
base['FED_Signal']= base.apply(lambda r: classify_signal(r['FED_YoY'], r['FED_3M'], **FED_THR), axis=1)
base['RES_Signal']= base.apply(lambda r: classify_signal(r['RES_YoY'], r['RES_3M'], **RES_THR), axis=1)
base['RRP_Signal']= base.apply(lambda r: classify_signal(-r['RRP_YoY'] if pd.notna(r['RRP_YoY']) else np.nan,
-r['RRP_3M'] if pd.notna(r['RRP_3M']) else np.nan,
**RRP_THR), axis=1)
# Composite (z-score blend) and regime
comp_cols = {
'M2': zscore(base['M2_YoY']) + zscore(base['M2_3M']),
'FED': zscore(base['FED_YoY']) + zscore(base['FED_3M']),
'RES': zscore(base['RES_YoY']) + zscore(base['RES_3M']),
'RRP': zscore(-base['RRP_YoY']) + zscore(-base['RRP_3M']),
}
base['Liquidity_Composite'] = pd.DataFrame(comp_cols).mean(axis=1)
base['Liquidity_Regime'] = pd.cut(base['Liquidity_Composite'],
bins=[-np.inf,-0.75,0.75,np.inf],
labels=['Contraction','Neutral','Expansion'])
6. Assumptions & Limitations
- Weekly/daily series reduced to month‑end can suppress intra‑month swings; use weekly views for trading signals if needed.
- YoY changes are revision‑sensitive around structural breaks (e.g., QE/QT episodes); thresholds may need recalibration.
- Equal weighting assumes similar information value across inputs; alternative weights can be back‑tested.
7. Reproducibility
- Persist source IDs (M2SL, WALCL, WRESBAL, RRPONTSYD), download timestamps, and code commit hash.
- Record years_back, month‑end alignment policy, and threshold dictionaries.
- Store the panel (
base) and outputs (Liquidity_Composite,Liquidity_Regime) with dates for audit.
8. Applications
Suitable for macro dashboards, conditioning variables in cross‑asset models, and overlay rules for risk‑on/off tilts. Pairs naturally with price‑based signals (e.g., real‑yield or CPI–PPI divergence) for triangulation.