M2 Money Supply Signal
Abstract
This document formalises a rule‑based signal derived from the growth and short‑term momentum of the US M2 money stock (M2SL). We compute 12‑month and 3‑month percentage changes, then map observations to Bullish, Neutral, or Bearish regimes using transparent thresholds intended for macro‑risk applications (e.g., precious metals, cyclicals).
1. Data
- Series:
M2SL— M2 Money Stock (monthly, FRED).
The series is fetched from FRED with title metadata. Values are coerced to numeric and the date index is normalised to calendar month.
2. Preparation
- Fetch & typing: parse
Dateto datetime; coerceValueto numeric. - Missing data policy: apply linear interpolation, then
bfill/ffillto cover any edge NaNs. - Sanity checks: confirm no residual nulls after preparation.
3. Transformations
- Year‑over‑Year growth:
M2_{YoY,t} = 100 \times \frac{M2_t - M2_{t-12}}{M2_{t-12}}
- 3‑month momentum:
M2_{3M,t} = 100 \times \frac{M2_t - M2_{t-3}}{M2_{t-3}}
4. Signal Classification
Classify each timestamp using level (YoY) and impulse (3M) thresholds:
- High growth threshold:
high_yoy = 6.0 - Low growth threshold:
low_yoy = 2.0 - Acceleration threshold (3M):
accelerating_3m = 1.0 - Deceleration threshold (3M):
decelerating_3m = -1.0
Rules: if M2_{YoY} > 6.0 or M2_{3M} > 1.0 ⇒ Bullish; if M2_{YoY} < 2.0 or M2_{3M} < -1.0 ⇒ Bearish; else Neutral. Missing inputs default to Neutral.
5. Implementation Notes (Python)
def fetch_fred_series_df(series_id):
try:
s = fred.get_series(series_id)
if s is None or s.empty:
return pd.DataFrame(columns=['Series_ID','title','Date','Value'])
s.index = pd.to_datetime(s.index)
df = s.reset_index(); df.columns = ['Date','Value']
df['Series_ID'] = series_id
try:
meta = fred.get_series_info(series_id)
df['title'] = getattr(meta, 'title', None)
except Exception:
df['title'] = None
df['Value'] = pd.to_numeric(df['Value'], errors='coerce')
return df[['Series_ID','title','Date','Value']].sort_values(['Date','Series_ID']).reset_index(drop=True)
except Exception:
return pd.DataFrame(columns=['Series_ID','title','Date','Value'])
# Fetch & prepare
m2sl_df = fetch_fred_series_df('M2SL')
m2sl_df['Date'] = pd.to_datetime(m2sl_df['Date'])
m2sl_df['Value'] = pd.to_numeric(m2sl_df['Value'], errors='coerce')
# Handle missing values
a = m2sl_df.copy()
a.interpolate(method='linear', inplace=True)
a.fillna(method='bfill', inplace=True)
a.fillna(method='ffill', inplace=True)
# Transforms (YoY and 3M)
a['M2SL_YoY'] = a['Value'].pct_change(12) * 100
a['M2SL_3Month_Change'] = a['Value'].pct_change(3) * 100
# Signal rules
HIGH_YOY = 6.0; LOW_YOY = 2.0; ACC3M = 1.0; DEC3M = -1.0
def generate_m2_signal(row):
yoy, m3 = row['M2SL_YoY'], row['M2SL_3Month_Change']
if pd.isna(yoy) or pd.isna(m3):
return 'Neutral'
if (yoy > HIGH_YOY) or (m3 > ACC3M):
return 'Bullish'
if (yoy < LOW_YOY) or (m3 < DEC3M):
return 'Bearish'
return 'Neutral'
a['M2_Signal'] = a.apply(generate_m2_signal, axis=1)
6. Assumptions & Limitations
- Interpolation and edge fills assume smooth intra‑month behaviour; results may differ during sharp breaks.
- Fixed thresholds (6/2/±1) are heuristic; calibrate to target asset classes via backtests.
- YoY growth can be influenced by base effects; consider alternative horizons when base effects are extreme.
7. Reproducibility
- Persist source ID (
M2SL), retrieval timestamps, and library versions. - Log missing‑data treatment (interpolation, fills) and any threshold overrides.
- Store the output panel with
M2SL_YoY,M2SL_3Month_Change, andM2_Signalcolumns for audit.
8. Applications
Use as a standalone macro‑liquidity indicator or as an input to broader composites (e.g., the Federal Reserve Liquidity Composite). Helpful for conditioning risk‑on/off tilts in metals and cyclical assets.