{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "63efd0a9",
   "metadata": {},
   "source": [
    "# Test the Yield Curve Recession Indicator in Python\n",
    "\n",
    "## Complete US recession indicator analysis\n",
    "\n",
    "This notebook reproduces the analysis used in the article **Test the Yield Curve Recession Indicator in Python**.\n",
    "\n",
    "It tests two US Treasury yield spreads from FRED:\n",
    "\n",
    "* **T10Y2Y**: 10 year Treasury rate minus 2 year Treasury rate\n",
    "* **T10Y3M**: 10 year Treasury rate minus 3 month Treasury rate\n",
    "* **USREC**: US recession indicator\n",
    "\n",
    "The main backtest asks a simple question:\n",
    "\n",
    "> When a monthly yield spread becomes negative, does a US recession begin 6 to 24 months later?\n",
    "\n",
    "The notebook measures true signals, false signals, missed recessions, precision, recall, lead time, persistence, and forecast window sensitivity.\n",
    "\n",
    "### Reproducibility window\n",
    "\n",
    "The article checked daily spread data through **August 19, 2026**.  \n",
    "The aligned recession backtest ends with the latest monthly USREC observation available in the article, **July 2026**.\n",
    "\n",
    "You can change `USE_LIVE_DATA` to `True` if you want the notebook to use the newest observations available when you run it.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2035ce55",
   "metadata": {},
   "source": [
    "## 1. Install the required packages\n",
    "\n",
    "Run this once if your environment does not already have the packages.\n",
    "\n",
    "The notebook uses `pandas_datareader` for FRED. It also includes a direct FRED CSV fallback, so the analysis can still work if `pandas_datareader` is unavailable.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0c510b51",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Uncomment this line if needed.\n",
    "# %pip install pandas numpy matplotlib plotly pandas_datareader kaleido\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2d9a8bec",
   "metadata": {},
   "source": [
    "## 2. Imports and analysis settings\n",
    "\n",
    "The default settings match the article.\n",
    "\n",
    "A monthly inversion is defined as a monthly average spread below zero.  \n",
    "The baseline forecast window is 6 to 24 months after the signal.  \n",
    "The baseline persistence requirement is one inverted month.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ad4e97f2",
   "metadata": {},
   "outputs": [],
   "source": [
    "import warnings\n",
    "warnings.filterwarnings(\"ignore\")\n",
    "\n",
    "import numpy as np\n",
    "import pandas as pd\n",
    "import matplotlib.pyplot as plt\n",
    "import plotly.graph_objects as go\n",
    "from IPython.display import display\n",
    "\n",
    "ARTICLE_DAILY_END = pd.Timestamp(\"2026-08-19\")\n",
    "ARTICLE_MONTHLY_END = pd.Timestamp(\"2026-07-01\")\n",
    "START = pd.Timestamp(\"1976-01-01\")\n",
    "\n",
    "USE_LIVE_DATA = False\n",
    "\n",
    "if USE_LIVE_DATA:\n",
    "    DAILY_END = pd.Timestamp.today().normalize()\n",
    "    MONTHLY_END = None\n",
    "else:\n",
    "    DAILY_END = ARTICLE_DAILY_END\n",
    "    MONTHLY_END = ARTICLE_MONTHLY_END\n",
    "\n",
    "BASELINE_WINDOW = (6, 24)\n",
    "BASELINE_PERSISTENCE = 1\n",
    "\n",
    "print(\"Daily data end:\", DAILY_END.date())\n",
    "print(\"Monthly backtest cap:\", MONTHLY_END.date() if MONTHLY_END is not None else \"latest available\")\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f7af1a17",
   "metadata": {},
   "source": [
    "## 3. Download FRED data\n",
    "\n",
    "The preferred method uses `pandas_datareader`.\n",
    "\n",
    "If that package is missing or FRED access through it fails, the fallback reads FRED graph CSV files directly.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3283ec43",
   "metadata": {},
   "outputs": [],
   "source": [
    "SERIES = [\"T10Y2Y\", \"T10Y3M\", \"USREC\"]\n",
    "\n",
    "def load_fred(start, end):\n",
    "    try:\n",
    "        from pandas_datareader import data as web\n",
    "        data = web.DataReader(SERIES, \"fred\", start, end)\n",
    "        source = \"pandas_datareader\"\n",
    "        return data, source\n",
    "    except Exception as exc:\n",
    "        print(\"pandas_datareader method failed:\", exc)\n",
    "        print(\"Trying direct FRED CSV files instead.\")\n",
    "\n",
    "    frames = []\n",
    "    for series_id in SERIES:\n",
    "        url = f\"https://fred.stlouisfed.org/graph/fredgraph.csv?id={series_id}\"\n",
    "        temp = pd.read_csv(url)\n",
    "        temp.columns = [\"DATE\", series_id]\n",
    "        temp[\"DATE\"] = pd.to_datetime(temp[\"DATE\"])\n",
    "        temp[series_id] = pd.to_numeric(temp[series_id], errors=\"coerce\")\n",
    "        temp = temp.set_index(\"DATE\")\n",
    "        frames.append(temp)\n",
    "\n",
    "    data = pd.concat(frames, axis=1).sort_index()\n",
    "    data = data.loc[(data.index >= pd.Timestamp(start)) & (data.index <= pd.Timestamp(end))]\n",
    "    return data, \"FRED graph CSV\"\n",
    "\n",
    "raw, data_source = load_fred(START, DAILY_END)\n",
    "\n",
    "print(\"Data source:\", data_source)\n",
    "print(\"Rows:\", len(raw))\n",
    "display(raw.tail())\n",
    "\n",
    "for column in raw.columns:\n",
    "    non_null = raw[column].dropna()\n",
    "    print(\n",
    "        column,\n",
    "        \"first:\",\n",
    "        non_null.index.min().date() if len(non_null) else None,\n",
    "        \"last:\",\n",
    "        non_null.index.max().date() if len(non_null) else None,\n",
    "    )\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c659f174",
   "metadata": {},
   "source": [
    "## 4. Clean and align the data\n",
    "\n",
    "The two Treasury spreads are daily series. We convert them to monthly averages.\n",
    "\n",
    "USREC is monthly. The backtest stops at the last available recession label, or at July 2026 when reproducing the article.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "68338b18",
   "metadata": {},
   "outputs": [],
   "source": [
    "spreads = raw[[\"T10Y2Y\", \"T10Y3M\"]].resample(\"MS\").mean()\n",
    "\n",
    "recession = raw[\"USREC\"].dropna().resample(\"MS\").last()\n",
    "\n",
    "if MONTHLY_END is not None:\n",
    "    recession = recession.loc[:MONTHLY_END]\n",
    "\n",
    "analysis_end = recession.index.max()\n",
    "spreads = spreads.loc[:analysis_end]\n",
    "recession = recession.loc[:analysis_end]\n",
    "\n",
    "monthly = spreads.join(recession.rename(\"USREC\"), how=\"inner\")\n",
    "\n",
    "print(\"Backtest ends:\", analysis_end.date())\n",
    "print(\"T10Y2Y monthly sample starts:\", spreads[\"T10Y2Y\"].dropna().index.min().date())\n",
    "print(\"T10Y3M monthly sample starts:\", spreads[\"T10Y3M\"].dropna().index.min().date())\n",
    "\n",
    "display(monthly.tail(12))\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "03cef87f",
   "metadata": {},
   "source": [
    "## 5. Inspect the latest spread values\n",
    "\n",
    "Positive values mean the longer Treasury maturity yields more than the shorter maturity.\n",
    "\n",
    "Negative values mean the curve is inverted for that spread.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b2d49a7a",
   "metadata": {},
   "outputs": [],
   "source": [
    "latest_daily = raw[[\"T10Y2Y\", \"T10Y3M\"]].apply(lambda s: s.dropna().iloc[-1])\n",
    "latest_dates = raw[[\"T10Y2Y\", \"T10Y3M\"]].apply(lambda s: s.dropna().index[-1])\n",
    "\n",
    "latest_table = pd.DataFrame({\n",
    "    \"latest_date\": latest_dates,\n",
    "    \"spread_percentage_points\": latest_daily,\n",
    "    \"inverted\": latest_daily < 0,\n",
    "})\n",
    "\n",
    "display(latest_table)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "623f1b50",
   "metadata": {},
   "source": [
    "## 6. Define recession starts and inversion scoring\n",
    "\n",
    "Each continuous run of negative monthly spread values is treated as one inversion episode.\n",
    "\n",
    "For persistence greater than one, the signal date moves forward until the required number of consecutive inverted months has occurred.\n",
    "\n",
    "A signal is counted as:\n",
    "\n",
    "* **True positive** if a recession starts within the chosen forecast window\n",
    "* **False positive** if the full forecast window passes without a recession\n",
    "* **Open signal** if the forecast window has not yet fully passed\n",
    "* **Not scored** if the signal occurs while the economy is already in recession\n",
    "\n",
    "Recall is measured on recession events, not on signal episodes.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b5e57a09",
   "metadata": {},
   "outputs": [],
   "source": [
    "def month_diff(start, end):\n",
    "    return (end.year - start.year) * 12 + end.month - start.month\n",
    "\n",
    "\n",
    "def recession_start_dates(recession_series):\n",
    "    rec = recession_series.fillna(0).astype(int)\n",
    "    starts = rec.eq(1) & rec.shift(1, fill_value=0).eq(0)\n",
    "    return rec.index[starts]\n",
    "\n",
    "\n",
    "def score_spread(spread, recession_series, window=(6, 24), persistence=1):\n",
    "    spread = spread.dropna()\n",
    "    rec_starts = recession_start_dates(recession_series)\n",
    "\n",
    "    inverted = spread.lt(0)\n",
    "    episode_starts = spread.index[\n",
    "        inverted & ~inverted.shift(1, fill_value=False)\n",
    "    ]\n",
    "\n",
    "    rows = []\n",
    "\n",
    "    for episode_start in episode_starts:\n",
    "        after = spread.loc[episode_start:]\n",
    "        first_non_inverted = after[after.ge(0)]\n",
    "\n",
    "        episode_end = (\n",
    "            first_non_inverted.index[0]\n",
    "            if len(first_non_inverted)\n",
    "            else spread.index[-1] + pd.offsets.MonthBegin(1)\n",
    "        )\n",
    "\n",
    "        values = spread.loc[\n",
    "            episode_start : episode_end - pd.offsets.MonthBegin(1)\n",
    "        ]\n",
    "\n",
    "        if len(values) < persistence:\n",
    "            continue\n",
    "\n",
    "        signal_date = episode_start + pd.offsets.MonthBegin(persistence - 1)\n",
    "\n",
    "        if not spread.loc[episode_start:signal_date].lt(0).all():\n",
    "            continue\n",
    "\n",
    "        already_in_recession = bool(\n",
    "            recession_series.reindex([signal_date], fill_value=0).iloc[0]\n",
    "        )\n",
    "\n",
    "        matches = [\n",
    "            r for r in rec_starts\n",
    "            if r > signal_date\n",
    "            and window[0] <= month_diff(signal_date, r) <= window[1]\n",
    "        ]\n",
    "\n",
    "        next_recession = min(matches) if matches else pd.NaT\n",
    "\n",
    "        if already_in_recession:\n",
    "            outcome = \"Not scored: recession underway\"\n",
    "        elif pd.notna(next_recession):\n",
    "            outcome = \"True positive\"\n",
    "        elif signal_date + pd.offsets.MonthBegin(window[1]) > spread.index.max():\n",
    "            outcome = \"Open signal\"\n",
    "        else:\n",
    "            outcome = \"False positive\"\n",
    "\n",
    "        rows.append({\n",
    "            \"episode_start\": episode_start,\n",
    "            \"signal_date\": signal_date,\n",
    "            \"minimum_spread\": values.min(),\n",
    "            \"months_inverted\": len(values),\n",
    "            \"next_recession_start\": next_recession,\n",
    "            \"lead_months\": (\n",
    "                month_diff(signal_date, next_recession)\n",
    "                if pd.notna(next_recession)\n",
    "                else np.nan\n",
    "            ),\n",
    "            \"outcome\": outcome,\n",
    "        })\n",
    "\n",
    "    episodes = pd.DataFrame(rows)\n",
    "\n",
    "    if episodes.empty:\n",
    "        episodes = pd.DataFrame(columns=[\n",
    "            \"episode_start\",\n",
    "            \"signal_date\",\n",
    "            \"minimum_spread\",\n",
    "            \"months_inverted\",\n",
    "            \"next_recession_start\",\n",
    "            \"lead_months\",\n",
    "            \"outcome\",\n",
    "        ])\n",
    "\n",
    "    eligible_recessions = [\n",
    "        r for r in rec_starts\n",
    "        if r >= spread.index.min()\n",
    "        and r <= spread.index.max()\n",
    "        and month_diff(spread.index.min(), r) >= window[1]\n",
    "    ]\n",
    "\n",
    "    valid_signal_dates = episodes.loc[\n",
    "        episodes[\"outcome\"].isin(\n",
    "            [\"True positive\", \"False positive\", \"Open signal\"]\n",
    "        ),\n",
    "        \"signal_date\",\n",
    "    ].tolist()\n",
    "\n",
    "    false_negatives = 0\n",
    "    for recession_start in eligible_recessions:\n",
    "        covered = any(\n",
    "            signal < recession_start\n",
    "            and window[0] <= month_diff(signal, recession_start) <= window[1]\n",
    "            for signal in valid_signal_dates\n",
    "        )\n",
    "        false_negatives += int(not covered)\n",
    "\n",
    "    scored = episodes[\n",
    "        episodes[\"outcome\"].isin([\"True positive\", \"False positive\"])\n",
    "    ]\n",
    "\n",
    "    true_positives = int(\n",
    "        (scored[\"outcome\"] == \"True positive\").sum()\n",
    "    )\n",
    "    false_positives = int(\n",
    "        (scored[\"outcome\"] == \"False positive\").sum()\n",
    "    )\n",
    "\n",
    "    precision = (\n",
    "        true_positives / len(scored)\n",
    "        if len(scored)\n",
    "        else np.nan\n",
    "    )\n",
    "\n",
    "    recall = (\n",
    "        (len(eligible_recessions) - false_negatives)\n",
    "        / len(eligible_recessions)\n",
    "        if eligible_recessions\n",
    "        else np.nan\n",
    "    )\n",
    "\n",
    "    summary = {\n",
    "        \"scored_episodes\": len(scored),\n",
    "        \"open_signals\": int(\n",
    "            (episodes[\"outcome\"] == \"Open signal\").sum()\n",
    "        ),\n",
    "        \"true_positive_episodes\": true_positives,\n",
    "        \"false_positive_episodes\": false_positives,\n",
    "        \"false_negative_recessions\": false_negatives,\n",
    "        \"eligible_recessions\": len(eligible_recessions),\n",
    "        \"precision\": precision,\n",
    "        \"recall\": recall,\n",
    "        \"median_lead_months\": episodes.loc[\n",
    "            episodes[\"outcome\"] == \"True positive\",\n",
    "            \"lead_months\",\n",
    "        ].median(),\n",
    "        \"sample_start\": spread.index.min(),\n",
    "        \"sample_end\": spread.index.max(),\n",
    "    }\n",
    "\n",
    "    return episodes, summary\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "57de9e41",
   "metadata": {},
   "source": [
    "## 7. Run the baseline backtest\n",
    "\n",
    "Baseline settings:\n",
    "\n",
    "* Forecast window: 6 to 24 months\n",
    "* Persistence: 1 inverted month\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "97476e2c",
   "metadata": {},
   "outputs": [],
   "source": [
    "episode_tables = {}\n",
    "summary_rows = []\n",
    "\n",
    "for series_id in [\"T10Y2Y\", \"T10Y3M\"]:\n",
    "    episodes, summary = score_spread(\n",
    "        spreads[series_id],\n",
    "        recession,\n",
    "        window=BASELINE_WINDOW,\n",
    "        persistence=BASELINE_PERSISTENCE,\n",
    "    )\n",
    "\n",
    "    episode_tables[series_id] = episodes\n",
    "    summary_rows.append({\n",
    "        \"spread\": series_id,\n",
    "        **summary,\n",
    "    })\n",
    "\n",
    "summary_table = pd.DataFrame(summary_rows)\n",
    "\n",
    "summary_display = summary_table.copy()\n",
    "summary_display[\"precision\"] = summary_display[\"precision\"].map(\n",
    "    lambda x: f\"{x:.1%}\" if pd.notna(x) else \"\"\n",
    ")\n",
    "summary_display[\"recall\"] = summary_display[\"recall\"].map(\n",
    "    lambda x: f\"{x:.1%}\" if pd.notna(x) else \"\"\n",
    ")\n",
    "\n",
    "display(summary_display)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3f695abe",
   "metadata": {},
   "source": [
    "### Article benchmark\n",
    "\n",
    "When the article was produced with its stated cutoff, the baseline summary was:\n",
    "\n",
    "| Spread | Scored episodes | Open | True positive episodes | False positive episodes | False negative recessions | Precision | Recall | Median lead |\n",
    "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |\n",
    "| T10Y2Y | 11 | 0 | 8 | 3 | 1 | 72.7% | 83.3% | 15.5 months |\n",
    "| T10Y3M | 7 | 2 | 5 | 2 | 0 | 71.4% | 100.0% | 10.0 months |\n",
    "\n",
    "Small differences can appear if FRED later revises historical observations.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ad618cd0",
   "metadata": {},
   "source": [
    "## 8. Show every inversion episode\n",
    "\n",
    "These tables are the most useful audit trail in the notebook. They let you see which signals worked, which did not, and which were still open at the article cutoff.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4e899e0d",
   "metadata": {},
   "outputs": [],
   "source": [
    "for series_id in [\"T10Y2Y\", \"T10Y3M\"]:\n",
    "    print(\"\\n\", series_id)\n",
    "    table = episode_tables[series_id].copy()\n",
    "\n",
    "    if not table.empty:\n",
    "        table[\"minimum_spread\"] = table[\"minimum_spread\"].round(2)\n",
    "        table[\"lead_months\"] = table[\"lead_months\"].astype(\"Int64\")\n",
    "\n",
    "    display(table)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d06a9d4b",
   "metadata": {},
   "source": [
    "## 9. Check recession coverage directly\n",
    "\n",
    "This table asks the reverse question.\n",
    "\n",
    "Instead of starting with inversion episodes, it starts with each eligible recession and checks whether at least one valid signal appeared 6 to 24 months earlier.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ff7ad575",
   "metadata": {},
   "outputs": [],
   "source": [
    "def recession_coverage_table(spread, recession_series, episodes, window=(6, 24)):\n",
    "    spread = spread.dropna()\n",
    "    rec_starts = recession_start_dates(recession_series)\n",
    "\n",
    "    eligible = [\n",
    "        r for r in rec_starts\n",
    "        if r >= spread.index.min()\n",
    "        and r <= spread.index.max()\n",
    "        and month_diff(spread.index.min(), r) >= window[1]\n",
    "    ]\n",
    "\n",
    "    signal_dates = episodes.loc[\n",
    "        episodes[\"outcome\"].isin(\n",
    "            [\"True positive\", \"False positive\", \"Open signal\"]\n",
    "        ),\n",
    "        \"signal_date\",\n",
    "    ].tolist()\n",
    "\n",
    "    rows = []\n",
    "    for rec_start in eligible:\n",
    "        matching = [\n",
    "            s for s in signal_dates\n",
    "            if s < rec_start\n",
    "            and window[0] <= month_diff(s, rec_start) <= window[1]\n",
    "        ]\n",
    "\n",
    "        rows.append({\n",
    "            \"recession_start\": rec_start,\n",
    "            \"covered\": bool(matching),\n",
    "            \"signal_dates\": \", \".join(\n",
    "                pd.Timestamp(x).strftime(\"%Y-%m\")\n",
    "                for x in matching\n",
    "            ),\n",
    "            \"lead_months\": \", \".join(\n",
    "                str(month_diff(s, rec_start))\n",
    "                for s in matching\n",
    "            ),\n",
    "        })\n",
    "\n",
    "    return pd.DataFrame(rows)\n",
    "\n",
    "for series_id in [\"T10Y2Y\", \"T10Y3M\"]:\n",
    "    print(\"\\nCoverage for\", series_id)\n",
    "    coverage = recession_coverage_table(\n",
    "        spreads[series_id],\n",
    "        recession,\n",
    "        episode_tables[series_id],\n",
    "        window=BASELINE_WINDOW,\n",
    "    )\n",
    "    display(coverage)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "72379c7d",
   "metadata": {},
   "source": [
    "## 10. Persistence robustness test\n",
    "\n",
    "A one month inversion can be noisy.\n",
    "\n",
    "This test requires one, two, or three consecutive inverted months before a signal becomes valid. A persistence rule can reduce false signals, but it can also delay the signal.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3e7c48db",
   "metadata": {},
   "outputs": [],
   "source": [
    "robustness_rows = []\n",
    "\n",
    "for persistence in [1, 2, 3]:\n",
    "    for series_id in [\"T10Y2Y\", \"T10Y3M\"]:\n",
    "        _, summary = score_spread(\n",
    "            spreads[series_id],\n",
    "            recession,\n",
    "            window=(6, 24),\n",
    "            persistence=persistence,\n",
    "        )\n",
    "\n",
    "        robustness_rows.append({\n",
    "            \"spread\": series_id,\n",
    "            \"persistence_months\": persistence,\n",
    "            \"scored_episodes\": summary[\"scored_episodes\"],\n",
    "            \"open_signals\": summary[\"open_signals\"],\n",
    "            \"precision\": summary[\"precision\"],\n",
    "            \"recall\": summary[\"recall\"],\n",
    "            \"median_lead_months\": summary[\"median_lead_months\"],\n",
    "        })\n",
    "\n",
    "robustness = pd.DataFrame(robustness_rows)\n",
    "\n",
    "robustness_display = robustness.copy()\n",
    "robustness_display[\"precision\"] = robustness_display[\"precision\"].map(\n",
    "    lambda x: f\"{x:.1%}\" if pd.notna(x) else \"\"\n",
    ")\n",
    "robustness_display[\"recall\"] = robustness_display[\"recall\"].map(\n",
    "    lambda x: f\"{x:.1%}\" if pd.notna(x) else \"\"\n",
    ")\n",
    "\n",
    "display(robustness_display)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2f803bfe",
   "metadata": {},
   "source": [
    "### Article benchmark for persistence\n",
    "\n",
    "| Spread | Persistence | Scored | Open | Precision | Recall | Median lead |\n",
    "| --- | ---: | ---: | ---: | ---: | ---: | ---: |\n",
    "| T10Y2Y | 1 | 11 | 0 | 72.7% | 83.3% | 15.5 |\n",
    "| T10Y3M | 1 | 7 | 2 | 71.4% | 100.0% | 10.0 |\n",
    "| T10Y2Y | 2 | 8 | 0 | 87.5% | 83.3% | 16.0 |\n",
    "| T10Y3M | 2 | 6 | 2 | 83.3% | 100.0% | 9.0 |\n",
    "| T10Y2Y | 3 | 6 | 0 | 83.3% | 83.3% | 15.0 |\n",
    "| T10Y3M | 3 | 5 | 1 | 80.0% | 100.0% | 10.0 |\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c2b25d24",
   "metadata": {},
   "source": [
    "## 11. Forecast window sensitivity\n",
    "\n",
    "The forecast window is a modeling choice.\n",
    "\n",
    "A narrower window can make a valid early warning look wrong simply because the recession arrived later than the cutoff.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "77aa90e8",
   "metadata": {},
   "outputs": [],
   "source": [
    "window_rows = []\n",
    "\n",
    "for window in [(6, 24), (6, 18), (3, 24), (9, 24)]:\n",
    "    for series_id in [\"T10Y2Y\", \"T10Y3M\"]:\n",
    "        _, summary = score_spread(\n",
    "            spreads[series_id],\n",
    "            recession,\n",
    "            window=window,\n",
    "            persistence=1,\n",
    "        )\n",
    "\n",
    "        window_rows.append({\n",
    "            \"spread\": series_id,\n",
    "            \"forecast_window\": f\"{window[0]} to {window[1]} months\",\n",
    "            \"scored_episodes\": summary[\"scored_episodes\"],\n",
    "            \"open_signals\": summary[\"open_signals\"],\n",
    "            \"precision\": summary[\"precision\"],\n",
    "            \"recall\": summary[\"recall\"],\n",
    "            \"median_lead_months\": summary[\"median_lead_months\"],\n",
    "        })\n",
    "\n",
    "window_sensitivity = pd.DataFrame(window_rows)\n",
    "\n",
    "window_display = window_sensitivity.copy()\n",
    "window_display[\"precision\"] = window_display[\"precision\"].map(\n",
    "    lambda x: f\"{x:.1%}\" if pd.notna(x) else \"\"\n",
    ")\n",
    "window_display[\"recall\"] = window_display[\"recall\"].map(\n",
    "    lambda x: f\"{x:.1%}\" if pd.notna(x) else \"\"\n",
    ")\n",
    "\n",
    "display(window_display)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2a156fdc",
   "metadata": {},
   "source": [
    "## 12. Static recession chart\n",
    "\n",
    "The chart shows both Treasury spreads around the zero line.\n",
    "\n",
    "Recession periods are shaded. A negative spread is an inversion.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "eb29fd46",
   "metadata": {},
   "outputs": [],
   "source": [
    "plot_data = spreads[[\"T10Y2Y\", \"T10Y3M\"]].copy()\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(14, 7))\n",
    "\n",
    "ax.plot(\n",
    "    plot_data.index,\n",
    "    plot_data[\"T10Y2Y\"],\n",
    "    label=\"10 year minus 2 year\",\n",
    ")\n",
    "ax.plot(\n",
    "    plot_data.index,\n",
    "    plot_data[\"T10Y3M\"],\n",
    "    label=\"10 year minus 3 month\",\n",
    ")\n",
    "\n",
    "ax.axhline(0, linewidth=1)\n",
    "\n",
    "rec = recession.fillna(0).astype(int)\n",
    "rec_starts = rec.index[\n",
    "    rec.eq(1) & rec.shift(1, fill_value=0).eq(0)\n",
    "]\n",
    "rec_ends = rec.index[\n",
    "    rec.eq(0) & rec.shift(1, fill_value=0).eq(1)\n",
    "]\n",
    "\n",
    "if len(rec_ends) < len(rec_starts):\n",
    "    rec_ends = rec_ends.append(\n",
    "        pd.DatetimeIndex([recession.index.max() + pd.offsets.MonthBegin(1)])\n",
    "    )\n",
    "\n",
    "for start, end in zip(rec_starts, rec_ends):\n",
    "    ax.axvspan(start, end, alpha=0.15)\n",
    "\n",
    "ax.set_title(\"US Treasury Yield Spreads and Recession Periods\")\n",
    "ax.set_ylabel(\"Spread in percentage points\")\n",
    "ax.set_xlabel(\"Date\")\n",
    "ax.legend()\n",
    "plt.tight_layout()\n",
    "plt.show()\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e60f5093",
   "metadata": {},
   "source": [
    "## 13. Interactive Plotly chart\n",
    "\n",
    "Use the range slider to zoom into an inversion period.\n",
    "\n",
    "Hovering over the chart makes it easier to inspect the spread value around a recession.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6484f6ca",
   "metadata": {},
   "outputs": [],
   "source": [
    "fig = go.Figure()\n",
    "\n",
    "for series_id, label in [\n",
    "    (\"T10Y2Y\", \"10 year minus 2 year\"),\n",
    "    (\"T10Y3M\", \"10 year minus 3 month\"),\n",
    "]:\n",
    "    fig.add_trace(\n",
    "        go.Scatter(\n",
    "            x=spreads.index,\n",
    "            y=spreads[series_id],\n",
    "            mode=\"lines\",\n",
    "            name=label,\n",
    "        )\n",
    "    )\n",
    "\n",
    "fig.add_hline(y=0, line_width=1)\n",
    "\n",
    "for start, end in zip(rec_starts, rec_ends):\n",
    "    fig.add_vrect(\n",
    "        x0=start,\n",
    "        x1=end,\n",
    "        opacity=0.12,\n",
    "        line_width=0,\n",
    "    )\n",
    "\n",
    "fig.update_xaxes(rangeslider_visible=True)\n",
    "fig.update_layout(\n",
    "    title=\"Interactive US Yield Curve Recession Indicator\",\n",
    "    yaxis_title=\"Spread in percentage points\",\n",
    "    hovermode=\"x unified\",\n",
    ")\n",
    "\n",
    "fig.show()\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a103a51a",
   "metadata": {},
   "source": [
    "## 14. Lead time distribution\n",
    "\n",
    "This view summarizes how many months passed between a successful inversion signal and the next recession start.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "adef0861",
   "metadata": {},
   "outputs": [],
   "source": [
    "lead_rows = []\n",
    "\n",
    "for series_id, episodes in episode_tables.items():\n",
    "    successful = episodes[\n",
    "        episodes[\"outcome\"] == \"True positive\"\n",
    "    ]\n",
    "\n",
    "    for value in successful[\"lead_months\"].dropna():\n",
    "        lead_rows.append({\n",
    "            \"spread\": series_id,\n",
    "            \"lead_months\": int(value),\n",
    "        })\n",
    "\n",
    "lead_times = pd.DataFrame(lead_rows)\n",
    "\n",
    "display(\n",
    "    lead_times.groupby(\"spread\")[\"lead_months\"]\n",
    "    .agg([\"count\", \"min\", \"median\", \"mean\", \"max\"])\n",
    "    .round(1)\n",
    ")\n",
    "\n",
    "if not lead_times.empty:\n",
    "    fig, ax = plt.subplots(figsize=(10, 5))\n",
    "    for series_id in lead_times[\"spread\"].unique():\n",
    "        values = lead_times.loc[\n",
    "            lead_times[\"spread\"] == series_id,\n",
    "            \"lead_months\",\n",
    "        ]\n",
    "        ax.hist(values, alpha=0.5, label=series_id)\n",
    "\n",
    "    ax.set_title(\"Lead Time of Successful Yield Curve Signals\")\n",
    "    ax.set_xlabel(\"Months before recession\")\n",
    "    ax.set_ylabel(\"Number of signals\")\n",
    "    ax.legend()\n",
    "    plt.tight_layout()\n",
    "    plt.show()\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "136d3b93",
   "metadata": {},
   "source": [
    "## 15. Compare the two spreads\n",
    "\n",
    "The main lesson is not that one spread is always right.\n",
    "\n",
    "The 10 year minus 3 month spread has stronger recession coverage in the available article sample. The 10 year minus 2 year spread has a longer history and also performs well, but it misses one eligible recession under the baseline rule.\n",
    "\n",
    "Both can produce false signals. Short inversions are especially important to inspect because they can reduce precision.\n",
    "\n",
    "That is why a practical recession dashboard should combine yield curve information with other measures such as labor market conditions, credit conditions, business activity, inflation, and real income.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "dd2a819a",
   "metadata": {},
   "outputs": [],
   "source": [
    "comparison = summary_table[\n",
    "    [\n",
    "        \"spread\",\n",
    "        \"scored_episodes\",\n",
    "        \"open_signals\",\n",
    "        \"true_positive_episodes\",\n",
    "        \"false_positive_episodes\",\n",
    "        \"false_negative_recessions\",\n",
    "        \"precision\",\n",
    "        \"recall\",\n",
    "        \"median_lead_months\",\n",
    "    ]\n",
    "].copy()\n",
    "\n",
    "comparison[\"precision\"] = comparison[\"precision\"].map(\n",
    "    lambda x: f\"{x:.1%}\" if pd.notna(x) else \"\"\n",
    ")\n",
    "comparison[\"recall\"] = comparison[\"recall\"].map(\n",
    "    lambda x: f\"{x:.1%}\" if pd.notna(x) else \"\"\n",
    ")\n",
    "\n",
    "display(comparison)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "aa7ccaff",
   "metadata": {},
   "source": [
    "## 16. Optional benchmark checks\n",
    "\n",
    "These checks compare the reproduced baseline with the article's published benchmark.\n",
    "\n",
    "They are warnings rather than hard failures because FRED can revise historical values.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5992a0e7",
   "metadata": {},
   "outputs": [],
   "source": [
    "expected = {\n",
    "    \"T10Y2Y\": {\n",
    "        \"scored_episodes\": 11,\n",
    "        \"open_signals\": 0,\n",
    "        \"true_positive_episodes\": 8,\n",
    "        \"false_positive_episodes\": 3,\n",
    "        \"false_negative_recessions\": 1,\n",
    "        \"precision\": 0.727,\n",
    "        \"recall\": 0.833,\n",
    "        \"median_lead_months\": 15.5,\n",
    "    },\n",
    "    \"T10Y3M\": {\n",
    "        \"scored_episodes\": 7,\n",
    "        \"open_signals\": 2,\n",
    "        \"true_positive_episodes\": 5,\n",
    "        \"false_positive_episodes\": 2,\n",
    "        \"false_negative_recessions\": 0,\n",
    "        \"precision\": 0.714,\n",
    "        \"recall\": 1.000,\n",
    "        \"median_lead_months\": 10.0,\n",
    "    },\n",
    "}\n",
    "\n",
    "for _, row in summary_table.iterrows():\n",
    "    series_id = row[\"spread\"]\n",
    "    ref = expected[series_id]\n",
    "\n",
    "    print(\"\\n\", series_id)\n",
    "\n",
    "    for key, expected_value in ref.items():\n",
    "        actual = row[key]\n",
    "\n",
    "        if isinstance(expected_value, float):\n",
    "            close = np.isclose(actual, expected_value, atol=0.015, equal_nan=True)\n",
    "        else:\n",
    "            close = actual == expected_value\n",
    "\n",
    "        print(\n",
    "            f\"{key}: actual={actual} | article={expected_value} | \"\n",
    "            f\"{'OK' if close else 'CHECK'}\"\n",
    "        )\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6a091183",
   "metadata": {},
   "source": [
    "## 17. Export analysis tables\n",
    "\n",
    "This cell saves the main tables as CSV files next to the notebook.\n",
    "\n",
    "It is optional.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "73aebe39",
   "metadata": {},
   "outputs": [],
   "source": [
    "summary_table.to_csv(\"yield_curve_summary.csv\", index=False)\n",
    "robustness.to_csv(\"yield_curve_persistence_robustness.csv\", index=False)\n",
    "window_sensitivity.to_csv(\"yield_curve_window_sensitivity.csv\", index=False)\n",
    "\n",
    "for series_id, table in episode_tables.items():\n",
    "    table.to_csv(\n",
    "        f\"{series_id.lower()}_inversion_episodes.csv\",\n",
    "        index=False,\n",
    "    )\n",
    "\n",
    "print(\"CSV exports created.\")\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "68795932",
   "metadata": {},
   "source": [
    "## 18. Final interpretation\n",
    "\n",
    "The yield curve is best treated as an early warning indicator, not a recession clock.\n",
    "\n",
    "In the article sample, both tested spreads had useful forecasting value. The 10 year minus 3 month spread covered all eligible recession starts in its shorter sample under the baseline rule. The 10 year minus 2 year spread covered most of them and offered a longer history.\n",
    "\n",
    "The weak point is timing. Recessions can begin many months after an inversion, and some inversions do not lead to a recession inside the chosen window.\n",
    "\n",
    "For research or investment work, the strongest use is to combine the yield curve with other US economic indicators rather than rely on one spread alone.\n",
    "\n",
    "### Data source\n",
    "\n",
    "Federal Reserve Bank of St. Louis, FRED.\n",
    "\n",
    "Series used: T10Y2Y, T10Y3M, and USREC.\n"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.x"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
