Housing Lead

Composite leading indicator for the housing cycle using permits, housing starts momentum, and mortgage‑rate headwinds.

Abstract

The Housing Lead signal blends forward indicators of residential activity (building permits and housing starts) with the financing constraint captured by the 30‑year mortgage rate. Momentum in permits (6‑month annualised) and starts (3‑month annualised) are positively related to future construction, while higher mortgage rates are a drag. Robust z‑scores and fixed weights produce a stable, interpretable composite with regimes: Expanding, Neutral, and Contracting.

1. Data

Ensure consistent frequency (month‑end) before constructing the composite.

2. Transformations

  1. Permits momentum: six‑month annualised growth
    PERMIT\_{6mAnn,t} = (PERMIT_t / PERMIT_{t−6})^2 − 1
  2. Starts momentum: three‑month annualised growth
    HOUST\_{3mAnn,t} = (HOUST_t / HOUST_{t−3})^4 − 1
  3. Mortgage headwind: use the level of MORTGAGE30US; higher rates are negative for activity.

3. Normalisation

z\_{rob}(X)_t = \dfrac{X_t − \mathrm{median}(X)_{t,w}}{1.4826·\mathrm{MAD}(X)_{t,w}}
with rolling window w = 48 months (minimum 18). Robust to outliers and structural level shifts.

4. Composite Construction

Weighting emphasises forward permit signals, then starts, with a smaller penalty from mortgage rates:

Housing\_{Lead,t} = 0.45·z(PERMIT\_{6mAnn,t}) + 0.35·z(HOUST\_{3mAnn,t}) + 0.20·z(−MORTGAGE30US_t)

5. Regime Mapping

If\ C_t > 0.75 → \textit{Expanding};\quad |C_t| ≤ 0.75 → \textit{Neutral};\quad C_t < −0.75 → \textit{Contracting}

6. Implementation (Python)

import pandas as pd
import numpy as np

def robust_z(s, win=48, min_win=18):
    x = pd.to_numeric(s, errors="coerce").astype(float)
    w = max(min_win, min(win, x.dropna().size))
    med = x.rolling(w, min_periods=min_win).median()
    mad = (x - med).abs().rolling(w, min_periods=min_win).median()
    return (x - med) / (1.4826 * mad.replace(0, np.nan))

d = df_housing.copy()

d["PERMIT_6mAnn"] = (d["PERMIT"] / d["PERMIT"].shift(6))**2 - 1.0

d["HOUST_3mAnn"]  = (d["HOUST"] / d["HOUST"].shift(3))**4 - 1.0

d["Z_PERMIT"] = robust_z(d["PERMIT_6mAnn"]) 

d["Z_HOUST"]  = robust_z(d["HOUST_3mAnn"]) 

d["Z_MORT"]   = robust_z(-d["MORTGAGE30US"]) 

d["Housing_Lead"] = (0.45*d["Z_PERMIT"] + 0.35*d["Z_HOUST"] + 0.20*d["Z_MORT"]) 

hi, lo = 0.75, -0.75

def _regime(v):
    if pd.isna(v): return np.nan
    return "Expanding" if v > hi else ("Contracting" if v < lo else "Neutral")

d["Housing_Regime"] = d["Housing_Lead"].apply(_regime)

df_sig_housing = d

display(df_sig_housing.tail())

7. Interpretation

8. Limitations