"""yield-curve-inversions-since-1976.py — list every 10y-2y inversion episode since 1976 and its lead to the next recession. Input : datasets/yield-curve-inversions-since-1976.csv (FRED DGS10, DGS2, T10Y2Y daily; USREC monthly), retrieved 2026-09-05 with the public fredgraph CSV (no key). Re-download with --download. Output: a Markdown table of inversion episodes (consecutive negative T10Y2Y closes, gaps of MAX_GAP or fewer non-negative closes bridged), the lead time to the next NBER recession start (USREC 0 -> 1), and summary counts. Every figure in the article comes from this listing. Run : python code/yield-curve-inversions-since-1976.py [--download] Needs : Python 3.13, pandas 3.0.2 (numpy 2.x). Standard library otherwise. """ import io import sys import urllib.request import pandas as pd CSV = "datasets/yield-curve-inversions-since-1976.csv" MAX_GAP = 5 # non-negative closes that do NOT end an episode MIN_DAYS = 5 # episodes with fewer negative closes are reported only as a count def download(): frames = [] for sid in ("DGS10", "DGS2", "T10Y2Y", "USREC"): url = f"https://fred.stlouisfed.org/graph/fredgraph.csv?id={sid}" req = urllib.request.Request(url, headers={"User-Agent": "prism-data-lab/1.0"}) txt = urllib.request.urlopen(req, timeout=60).read().decode() s = pd.read_csv(io.StringIO(txt), na_values=".", index_col=0).iloc[:, 0].rename(sid) frames.append(s) df = pd.concat(frames, axis=1) df.index.name = "date" df[df.index >= "1976-01-01"].to_csv(CSV) def main(): if "--download" in sys.argv: download() df = pd.read_csv(CSV, parse_dates=["date"], index_col="date") spread = df["T10Y2Y"].dropna() rec = df["USREC"].dropna() rec_starts = rec.index[(rec == 1) & (rec.shift(1, fill_value=0) == 0)] neg = spread < 0 episodes, start, last_neg, gap = [], None, None, 0 for d, is_neg in neg.items(): if is_neg: if start is None: start = d last_neg, gap = d, 0 elif start is not None: gap += 1 if gap > MAX_GAP: episodes.append((start, last_neg)) start = None if start is not None: episodes.append((start, last_neg)) rows, short = [], [] rec_m = rec.reindex(pd.date_range(rec.index[0], spread.index[-1], freq="D")).ffill() for s, e in episodes: seg = spread[s:e] days = int((seg < 0).sum()) if days < MIN_DAYS: short.append(f"{s:%Y-%m-%d} ({days})") continue nxt = [r for r in rec_starts if r > s] if nxt: months = (nxt[0].year - s.year) * 12 + nxt[0].month - s.month lead = f"{months} mo ({nxt[0]:%Y-%m})" else: lead = "none yet" inside = "yes" if rec_m.get(s, 0) == 1 else "no" rows.append((s, e, days, seg.min(), seg.idxmin(), lead, inside)) print("| # | first negative close | last negative close | negative closes | trough (pp) | trough date | began inside a recession | next recession start |") print("|---|---|---|---|---|---|---|---|") for i, (s, e, days, mn, mnd, lead, inside) in enumerate(rows, 1): print(f"| {i} | {s:%Y-%m-%d} | {e:%Y-%m-%d} | {days} | {mn:.2f} | {mnd:%Y-%m-%d} | {inside} | {lead} |") print() print(f"episodes with at least {MIN_DAYS} negative closes: {len(rows)}; shorter blips not tabulated: {len(short)}: {', '.join(short)}") print(f"negative closes {int(neg.sum())} of {len(spread)} observations ({100 * neg.mean():.1f}%), " f"{spread.index.min():%Y-%m-%d} to {spread.index.max():%Y-%m-%d}") print(f"latest observation: {spread.index[-1]:%Y-%m-%d} = {spread.iloc[-1]:.2f} pp") print("NBER recession starts in window:", ", ".join(f"{r:%Y-%m}" for r in rec_starts)) if __name__ == "__main__": main()