Federal Reserve Liquidity Composite

A reproducible specification blending M2, Fed balance‑sheet channels, and ON RRP into a monthly liquidity regime indicator.

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

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

  1. Month‑end alignment: resample each series to month‑end and select the last observation for that month.
  2. 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}
  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_3mBullish; if YoY < low_yoy or 3M < decel_3mBearish; else Neutral. Missing inputs default to Neutral.

4. Composite Construction

  1. Standardise: form z‑scores of YoY and 3M for each input (population standard deviation):
    z(s) = (s − μ(s)) / σ(s)
  2. Per‑input score: sum level and impulse components (e.g., z(M2_{YoY}) + z(M2_{3M})).
  3. 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}) )
  4. 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

7. Reproducibility

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.