{
 "nbformat": 4,
 "nbformat_minor": 5,
 "metadata": {
  "kernelspec": {
   "display_name": "Python (Pyodide)",
   "language": "python",
   "name": "python"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "python",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "version": "3.11.3"
  }
 },
 "cells": [
  {
   "cell_type": "markdown",
   "id": "a1b2c3d4",
   "metadata": {},
   "source": [
    "# Yield Curve Trading in the Eurozone\n",
    "\n",
    "**An interactive analysis of European government bond markets, sovereign spreads, and fixed-income trading strategies**\n",
    "\n",
    "---\n",
    "\n",
    "## Overview\n",
    "\n",
    "This notebook covers the following topics:\n",
    "\n",
    "1. **Yield Curve Fundamentals** — how yield curves are constructed and what they signal\n",
    "2. **Eurozone Bond Markets** — the major sovereign issuers and their characteristics\n",
    "3. **Sovereign Spread Analysis** — understanding cross-country yield differentials\n",
    "4. **Curve Shape Metrics** — measuring slope, curvature, and carry\n",
    "5. **Trading Strategies** — steepeners, flatteners, and butterfly trades\n",
    "6. **Duration & DV01** — fixed-income risk management fundamentals\n",
    "7. **ECB Policy Scenarios** — how monetary policy shapes the yield curve\n",
    "8. **Monte Carlo Simulation** — stress-testing strategies under yield curve scenarios\n",
    "\n",
    "> **Note:** All market data in this notebook is representative/synthetic for educational purposes. Run each cell sequentially with `Shift+Enter`."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b2c3d4e5",
   "metadata": {},
   "source": [
    "---\n",
    "## 1. Setup"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c3d4e5f6",
   "metadata": {},
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import pandas as pd\n",
    "import matplotlib.pyplot as plt\n",
    "import matplotlib.gridspec as gridspec\n",
    "import matplotlib.patches as mpatches\n",
    "import matplotlib.ticker as mticker\n",
    "from scipy.interpolate import CubicSpline\n",
    "from scipy.stats import norm\n",
    "import warnings\n",
    "warnings.filterwarnings('ignore')\n",
    "\n",
    "# Consistent plot styling\n",
    "plt.rcParams.update({\n",
    "    'figure.facecolor': '#fafafa',\n",
    "    'axes.facecolor': '#fafafa',\n",
    "    'axes.edgecolor': '#cccccc',\n",
    "    'axes.grid': True,\n",
    "    'grid.color': '#e0e0e0',\n",
    "    'grid.linewidth': 0.8,\n",
    "    'font.size': 11,\n",
    "    'axes.titlesize': 13,\n",
    "    'axes.titleweight': 'bold',\n",
    "    'axes.labelsize': 11,\n",
    "    'lines.linewidth': 2.5,\n",
    "    'figure.dpi': 100,\n",
    "})\n",
    "\n",
    "# Country colour palette inspired by national flags\n",
    "COLORS = {\n",
    "    'Germany':  '#003DA5',\n",
    "    'France':   '#0055A4',\n",
    "    'Italy':    '#009246',\n",
    "    'Spain':    '#AA151B',\n",
    "    'Portugal': '#006600',\n",
    "    'Greece':   '#0D5EAF',\n",
    "}\n",
    "\n",
    "# Standard maturities quoted in most Eurozone bond markets (years)\n",
    "MATURITIES = np.array([0.25, 0.5, 1, 2, 3, 5, 7, 10, 15, 20, 30])\n",
    "\n",
    "print('Setup complete.')\n",
    "print(f'NumPy {np.__version__} | Pandas {pd.__version__}')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d4e5f6a7",
   "metadata": {},
   "source": [
    "---\n",
    "## 2. Yield Curve Fundamentals\n",
    "\n",
    "A **yield curve** plots the relationship between bond maturity (x-axis) and yield-to-maturity (y-axis) for comparable securities — typically government bonds of the same credit quality.\n",
    "\n",
    "### Why the shape matters\n",
    "\n",
    "| Shape | Description | Signal |\n",
    "|-------|-------------|--------|\n",
    "| **Normal (upward-sloping)** | Long rates > Short rates | Healthy economy, expectations of future growth |\n",
    "| **Flat** | Long rates ≈ Short rates | Uncertainty, late-cycle, potential slowdown |\n",
    "| **Inverted** | Long rates < Short rates | Recession warning (very reliable historically) |\n",
    "| **Humped** | Peak at medium maturities | Transition between regimes |\n",
    "\n",
    "### Key Eurozone benchmarks\n",
    "- **German Bund** — the Eurozone risk-free benchmark; lowest yield of all sovereigns\n",
    "- **French OAT** — semi-core; typically 25–80 bps wide of Bunds\n",
    "- **Italian BTP** — largest peripheral market; spread vs Bund is the key risk barometer\n",
    "- **Spanish Bonos / Portuguese OT** — periphery; compressed closer to Germany after ECB's OMT pledge\n",
    "\n",
    "The **ECB deposit rate** anchors the short end. Long-end yields are driven by growth expectations, inflation, and risk premium."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e5f6a7b8",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Representative yield curves: three distinct rate regimes ──────────────────\n",
    "# All figures approximate historical levels (not live data)\n",
    "\n",
    "# Q4 2021 — Deep Negative Rate Era  (ECB deposit rate: -0.50%)\n",
    "era_2021 = {\n",
    "    'Germany':  np.array([-0.72, -0.69, -0.68, -0.71, -0.69, -0.57, -0.40, -0.22,  0.04,  0.09, -0.05]),\n",
    "    'France':   np.array([-0.65, -0.62, -0.60, -0.63, -0.61, -0.47, -0.28, -0.08,  0.20,  0.24,  0.14]),\n",
    "    'Italy':    np.array([-0.22, -0.17, -0.08,  0.12,  0.28,  0.65,  0.95,  1.18,  1.52,  1.72,  1.95]),\n",
    "    'Spain':    np.array([-0.40, -0.35, -0.27, -0.15,  0.00,  0.25,  0.52,  0.72,  1.05,  1.18,  1.35]),\n",
    "    'Portugal': np.array([-0.37, -0.32, -0.24, -0.10,  0.08,  0.30,  0.58,  0.82,  1.12,  1.28,  1.45]),\n",
    "}\n",
    "\n",
    "# Q4 2023 — Rate Hike Peak  (ECB deposit rate: 4.00%)\n",
    "era_2023 = {\n",
    "    'Germany':  np.array([ 3.82,  3.90,  3.78,  3.02,  2.75,  2.58,  2.58,  2.72,  2.85,  2.80,  2.70]),\n",
    "    'France':   np.array([ 3.95,  4.02,  3.88,  3.22,  2.98,  2.88,  2.95,  3.18,  3.35,  3.30,  3.20]),\n",
    "    'Italy':    np.array([ 4.35,  4.40,  4.30,  4.15,  4.08,  4.22,  4.48,  4.72,  5.05,  5.15,  5.10]),\n",
    "    'Spain':    np.array([ 4.05,  4.12,  4.00,  3.58,  3.42,  3.55,  3.75,  3.98,  4.25,  4.35,  4.28]),\n",
    "    'Portugal': np.array([ 4.02,  4.08,  3.95,  3.50,  3.30,  3.42,  3.62,  3.80,  4.05,  4.15,  4.08]),\n",
    "}\n",
    "\n",
    "# Q1 2025 — Easing Cycle  (ECB deposit rate: 2.75%)\n",
    "era_2025 = {\n",
    "    'Germany':  np.array([ 2.58,  2.62,  2.48,  2.05,  1.98,  2.12,  2.28,  2.48,  2.65,  2.72,  2.60]),\n",
    "    'France':   np.array([ 2.72,  2.75,  2.60,  2.25,  2.22,  2.45,  2.68,  2.98,  3.15,  3.22,  3.10]),\n",
    "    'Italy':    np.array([ 2.88,  2.92,  2.78,  2.70,  2.85,  3.20,  3.55,  3.80,  4.05,  4.15,  4.05]),\n",
    "    'Spain':    np.array([ 2.68,  2.72,  2.58,  2.38,  2.42,  2.72,  2.98,  3.20,  3.45,  3.55,  3.42]),\n",
    "    'Portugal': np.array([ 2.65,  2.68,  2.55,  2.32,  2.38,  2.68,  2.92,  3.10,  3.35,  3.45,  3.32]),\n",
    "}\n",
    "\n",
    "# Helper: smooth curve via cubic spline\n",
    "def smooth_curve(mats, yields, n=300):\n",
    "    cs = CubicSpline(mats, yields)\n",
    "    m = np.linspace(mats[0], mats[-1], n)\n",
    "    return m, cs(m)\n",
    "\n",
    "def interp_yield(mats, yields, target):\n",
    "    return float(CubicSpline(mats, yields)(target))\n",
    "\n",
    "print('Market data loaded for three rate environments.')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f6a7b8c9",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Visualise: Eurozone yield curves across three regimes ─────────────────────\n",
    "eras = [\n",
    "    (era_2021, 'Q4 2021 — Negative Rate Era', 'ECB: −0.50%'),\n",
    "    (era_2023, 'Q4 2023 — Rate Hike Peak',    'ECB:  4.00%'),\n",
    "    (era_2025, 'Q1 2025 — Easing Cycle',      'ECB:  2.75%'),\n",
    "]\n",
    "\n",
    "fig, axes = plt.subplots(1, 3, figsize=(18, 6), sharey=False)\n",
    "\n",
    "for ax, (era, title, ecb_label) in zip(axes, eras):\n",
    "    for country, yields in era.items():\n",
    "        m, y = smooth_curve(MATURITIES, yields)\n",
    "        ax.plot(m, y, color=COLORS[country], label=country)\n",
    "        ax.scatter(MATURITIES, yields, color=COLORS[country], s=28, zorder=5)\n",
    "\n",
    "    ymin, ymax = ax.get_ylim()\n",
    "    if ymin < 0:\n",
    "        ax.axhspan(ymin, 0, alpha=0.07, color='tomato', label='Negative yield zone')\n",
    "    ax.axhline(0, color='black', lw=0.9, ls='--', alpha=0.4)\n",
    "    ax.set_xlabel('Maturity (years)')\n",
    "    ax.set_ylabel('Yield (%)' if ax is axes[0] else '')\n",
    "    ax.set_title(f'{title}\\n{ecb_label}', pad=10)\n",
    "    ax.set_xlim(0, 31)\n",
    "    ax.legend(loc='upper left', fontsize=8.5, framealpha=0.9)\n",
    "\n",
    "fig.suptitle('Eurozone Government Bond Yield Curves — Three Rate Environments',\n",
    "             fontsize=14, fontweight='bold', y=1.03)\n",
    "plt.tight_layout()\n",
    "plt.show()\n",
    "\n",
    "print('Key observations:')\n",
    "print('  2021 — German yields deeply negative; peripheral spreads compressed by ECB QE')\n",
    "print('  2023 — Aggressive hiking pushed all curves positive; short end ≫ long end (inverted)')\n",
    "print('  2025 — ECB cuts drag short rates lower; curves re-steepen (bull steepener)')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a7b8c9d0",
   "metadata": {},
   "source": [
    "---\n",
    "## 3. Sovereign Spread Analysis\n",
    "\n",
    "In the Eurozone, all sovereigns share the euro but retain individual credit risk. The spread of a country's bonds over German Bunds (the benchmark) reflects:\n",
    "\n",
    "- **Fiscal sustainability** — debt/GDP ratio, primary balance, deficit trajectory\n",
    "- **ECB backstop credibility** — OMT (2012) and TPI (2022) eliminated \"redenomination risk\"\n",
    "- **Liquidity premium** — Bunds are the most liquid; peripheral markets trade at a premium\n",
    "- **Political risk** — Italian elections, fiscal consolidation commitments\n",
    "\n",
    "The **BTP-Bund spread** (Italy 10Y vs Germany 10Y) is the single most-watched risk gauge in European fixed income. Levels above ~250 bps historically trigger ECB intervention discussions."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b8c9d0e1",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Spread analysis across the curve ─────────────────────────────────────────\n",
    "fig, axes = plt.subplots(1, 3, figsize=(18, 6), sharey=False)\n",
    "\n",
    "for ax, (era, title, ecb_label) in zip(axes, eras):\n",
    "    bund = era['Germany']\n",
    "    for country, yields in era.items():\n",
    "        if country == 'Germany':\n",
    "            continue\n",
    "        spread_bps = (yields - bund) * 100\n",
    "        m, s = smooth_curve(MATURITIES, spread_bps)\n",
    "        ax.plot(m, s, color=COLORS[country], label=country)\n",
    "        ax.scatter(MATURITIES, spread_bps, color=COLORS[country], s=28, zorder=5)\n",
    "\n",
    "    ax.axhline(0, color='navy', lw=1.2, ls='-', alpha=0.4, label='Bund baseline')\n",
    "    ax.axhline(200, color='crimson', lw=0.9, ls=':', alpha=0.5, label='200 bps warning')\n",
    "    ax.set_xlabel('Maturity (years)')\n",
    "    ax.set_ylabel('Spread vs Bund (bps)' if ax is axes[0] else '')\n",
    "    ax.set_title(f'{title}\\n{ecb_label}', pad=10)\n",
    "    ax.set_xlim(0, 31)\n",
    "    ax.legend(loc='upper left', fontsize=8.5, framealpha=0.9)\n",
    "\n",
    "fig.suptitle('Eurozone Sovereign Spreads vs German Bunds — Across the Curve',\n",
    "             fontsize=14, fontweight='bold', y=1.03)\n",
    "plt.tight_layout()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c9d0e1f2",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Simulated BTP-Bund 10Y spread time series ─────────────────────────────────\n",
    "np.random.seed(42)\n",
    "dates = pd.date_range('2019-01', '2025-06', freq='ME')\n",
    "n = len(dates)\n",
    "\n",
    "# Regime-based simulation capturing key market events\n",
    "spread = np.concatenate([\n",
    "    np.linspace(255, 142, 14) + np.random.randn(14) * 8,   # 2019: gradual compression\n",
    "    np.linspace(142, 275, 3)  + np.random.randn(3)  * 18,  # Mar 2020: COVID spike\n",
    "    np.linspace(275, 108, 22) + np.random.randn(22) * 10,  # 2020-21: PEPP compression\n",
    "    np.linspace(108, 248, 10) + np.random.randn(10) * 14,  # 2022: rate-hike shock\n",
    "    np.linspace(248, 188, 12) + np.random.randn(12) * 12,  # 2023: normalization\n",
    "    np.linspace(188, 118, 9)  + np.random.randn(9)  * 8,   # 2024: easing cycle\n",
    "    np.linspace(118, 132, 6)  + np.random.randn(6)  * 6,   # 2025 YTD\n",
    "])[:n]\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(14, 5))\n",
    "ax.fill_between(dates, spread, alpha=0.18, color='#AA151B')\n",
    "ax.plot(dates, spread, color='#AA151B', lw=2.2, label='BTP-Bund 10Y spread')\n",
    "\n",
    "# Key thresholds\n",
    "ax.axhline(200, color='crimson',  ls='--', lw=1.2, alpha=0.7, label='200 bps — ECB vigilance')\n",
    "ax.axhline(150, color='#228B22',  ls='--', lw=1.2, alpha=0.7, label='150 bps — compressed/benign')\n",
    "ax.axhline(250, color='darkred',  ls=':',  lw=1.2, alpha=0.7, label='250 bps — stress threshold')\n",
    "\n",
    "# Annotations\n",
    "ax.annotate('COVID spike\\n~275 bps', xy=(dates[16], 265), fontsize=9, color='darkred',\n",
    "            arrowprops=dict(arrowstyle='->', color='darkred'), xytext=(dates[8], 310))\n",
    "ax.annotate('PEPP\\ncompression', xy=(dates[30], 115), fontsize=9, color='#228B22',\n",
    "            arrowprops=dict(arrowstyle='->', color='#228B22'), xytext=(dates[22], 80))\n",
    "ax.annotate('Rate hike\\nshock', xy=(dates[42], 240), fontsize=9, color='darkred',\n",
    "            arrowprops=dict(arrowstyle='->', color='darkred'), xytext=(dates[46], 290))\n",
    "\n",
    "ax.set_title('BTP-Bund 10Y Spread — Simulated Historical (2019–2025)', pad=10)\n",
    "ax.set_ylabel('Spread (bps)')\n",
    "ax.legend(loc='upper right', fontsize=9)\n",
    "ax.set_ylim(50, 350)\n",
    "plt.tight_layout()\n",
    "plt.show()\n",
    "\n",
    "print(f'Current (Q1 2025) BTP-Bund 10Y spread: ~{spread[-1]:.0f} bps')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d0e1f2a3",
   "metadata": {},
   "source": [
    "---\n",
    "## 4. Yield Curve Shape Metrics\n",
    "\n",
    "Traders decompose yield curve movements into three factors:\n",
    "\n",
    "| Factor | Definition | Example metric |\n",
    "|--------|------------|----------------|\n",
    "| **Level** | Parallel shift of the entire curve | 2Y + 5Y + 10Y average |\n",
    "| **Slope** | Difference between long and short yields | 2s10s spread, 5s30s spread |\n",
    "| **Curvature** | Relative richness of the belly vs wings | 2s5s10s butterfly |\n",
    "\n",
    "### Key slope spreads\n",
    "- **2s10s** (2-year vs 10-year): the most widely followed slope metric. Negative = inverted curve.\n",
    "- **5s30s** (5-year vs 30-year): the \"long end\" slope; sensitive to inflation expectations.\n",
    "- **2s5s10s Butterfly**: measures whether 5Y is rich (positive) or cheap (negative) relative to 2Y and 10Y."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e1f2a3b4",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Compute slope metrics for Germany across regimes ──────────────────────────\n",
    "def slope_metrics(era, country='Germany'):\n",
    "    y = era[country]\n",
    "    y2  = interp_yield(MATURITIES, y, 2)\n",
    "    y5  = interp_yield(MATURITIES, y, 5)\n",
    "    y10 = interp_yield(MATURITIES, y, 10)\n",
    "    y30 = interp_yield(MATURITIES, y, 30)\n",
    "\n",
    "    s_2s10  = (y10 - y2)  * 100          # bps\n",
    "    s_5s30  = (y30 - y5)  * 100\n",
    "    # Butterfly: 2 * 5Y − 2Y − 10Y  (positive => 5Y cheap vs wings)\n",
    "    butterfly = (2 * y5 - y2 - y10) * 100\n",
    "    return s_2s10, s_5s30, butterfly\n",
    "\n",
    "labels = ['Q4 2021\\n(Negative rates)', 'Q4 2023\\n(Hike peak)', 'Q1 2025\\n(Easing)']\n",
    "rows = [slope_metrics(e) for e in [era_2021, era_2023, era_2025]]\n",
    "df_metrics = pd.DataFrame(rows, index=labels, columns=['2s10s (bps)', '5s30s (bps)', '2s5s10s butterfly (bps)'])\n",
    "\n",
    "print('German Bund curve shape metrics:\\n')\n",
    "print(df_metrics.round(1).to_string())\n",
    "\n",
    "# Bar chart\n",
    "fig, axes = plt.subplots(1, 3, figsize=(16, 5))\n",
    "palette = ['#4472C4', '#ED7D31', '#70AD47']\n",
    "\n",
    "for ax, col, colour in zip(axes, df_metrics.columns, palette):\n",
    "    bars = ax.bar(labels, df_metrics[col], color=colour, alpha=0.85, edgecolor='white', linewidth=1.2)\n",
    "    ax.axhline(0, color='black', lw=1, alpha=0.5)\n",
    "    ax.set_title(col, pad=8)\n",
    "    ax.set_ylabel('Basis points')\n",
    "    for bar, val in zip(bars, df_metrics[col]):\n",
    "        ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + (1 if val >= 0 else -4),\n",
    "                f'{val:.1f}', ha='center', va='bottom', fontsize=10, fontweight='bold')\n",
    "\n",
    "fig.suptitle('German Bund — Yield Curve Shape Metrics Across Rate Regimes',\n",
    "             fontsize=13, fontweight='bold', y=1.03)\n",
    "plt.tight_layout()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f2a3b4c5",
   "metadata": {},
   "source": [
    "---\n",
    "## 5. Yield Curve Trading Strategies\n",
    "\n",
    "Fixed-income traders express views on the *shape* of the yield curve rather than (or in addition to) its level. The three classic strategies:\n",
    "\n",
    "### 5a. Steepener\n",
    "**View:** The yield curve will steepen (long rates rise relative to short rates, or short rates fall relative to long rates).\n",
    "\n",
    "- **Receive fixed** (i.e., buy) at the short end → profit if short rates fall\n",
    "- **Pay fixed** (i.e., sell) at the long end → profit if long rates rise\n",
    "- **Duration-neutral** construction: notionals chosen so DV01 of both legs is equal\n",
    "\n",
    "**Classic setup:** Bull steepener (short rates fall) vs Bear steepener (long rates rise)\n",
    "\n",
    "### 5b. Flattener\n",
    "**View:** The yield curve will flatten (long rates fall relative to short rates, or short rates rise).\n",
    "\n",
    "- **Pay fixed** at the short end\n",
    "- **Receive fixed** at the long end\n",
    "- Common during rate hike cycles when the central bank pushes short rates up faster than long rates\n",
    "\n",
    "### 5c. Butterfly (Curvature) Trade\n",
    "**View:** The belly of the curve is cheap (or rich) relative to the wings.\n",
    "\n",
    "- **Long belly / short wings** = long 5Y, short 2Y and 10Y (duration-neutral)\n",
    "- **Short belly / long wings** = short 5Y, long 2Y and 10Y (\"barbell\" position)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "a3b4c5d6",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Strategy simulation: Bull steepener on Bunds ──────────────────────────────\n",
    "# Scenario: ECB begins cutting rates; 2Y yield falls 150 bps, 10Y falls 50 bps\n",
    "\n",
    "def bond_price(face, coupon_rate, ytm, maturity, freq=2):\n",
    "    \"\"\"Price a fixed-coupon bond at given yield-to-maturity.\"\"\"\n",
    "    n = int(maturity * freq)\n",
    "    c = face * coupon_rate / freq\n",
    "    ytm_p = ytm / freq\n",
    "    periods = np.arange(1, n + 1)\n",
    "    cashflows = np.full(n, c)\n",
    "    cashflows[-1] += face\n",
    "    return np.sum(cashflows / (1 + ytm_p) ** periods)\n",
    "\n",
    "def modified_duration(face, coupon_rate, ytm, maturity, freq=2):\n",
    "    \"\"\"Modified duration (Macaulay / (1 + ytm/freq)).\"\"\"\n",
    "    n = int(maturity * freq)\n",
    "    c = face * coupon_rate / freq\n",
    "    ytm_p = ytm / freq\n",
    "    periods = np.arange(1, n + 1)\n",
    "    cashflows = np.full(n, c)\n",
    "    cashflows[-1] += face\n",
    "    price = np.sum(cashflows / (1 + ytm_p) ** periods)\n",
    "    mac_dur = np.sum(periods * cashflows / (1 + ytm_p) ** periods) / price / freq\n",
    "    return mac_dur / (1 + ytm / freq)\n",
    "\n",
    "def dv01(face, coupon_rate, ytm, maturity):\n",
    "    \"\"\"Dollar value of 1 basis point.\"\"\"\n",
    "    p_up   = bond_price(face, coupon_rate, ytm + 0.0001, maturity)\n",
    "    p_down = bond_price(face, coupon_rate, ytm - 0.0001, maturity)\n",
    "    return (p_down - p_up) / 2\n",
    "\n",
    "# Entry levels (Q4 2023 Bund levels — hike peak)\n",
    "face = 10_000_000  # EUR 10m notional per leg\n",
    "y2_entry  = 3.02 / 100\n",
    "y10_entry = 2.72 / 100\n",
    "\n",
    "dv01_2y  = dv01(face, y2_entry,  y2_entry,  2)\n",
    "dv01_10y = dv01(face, y10_entry, y10_entry, 10)\n",
    "ratio     = dv01_2y / dv01_10y\n",
    "\n",
    "print('Bull Steepener: Long 2Y Bund / Short 10Y Bund (duration-neutral)')\n",
    "print(f'  2Y notional  : EUR {face/1e6:.0f}m  DV01/bp = EUR {dv01_2y:,.0f}')\n",
    "print(f'  10Y notional : EUR {face*ratio/1e6:.1f}m  DV01/bp = EUR {dv01_10y*ratio:,.0f}')\n",
    "print(f'  Notional ratio: {ratio:.3f}x  (match DV01s so only slope, not level, risk is taken)')\n",
    "\n",
    "# Scenario: ECB easing → 2Y falls 150 bps, 10Y falls 50 bps → curve steepens 100 bps\n",
    "dy2  = -1.50 / 100\n",
    "dy10 = -0.50 / 100\n",
    "\n",
    "p2_entry  = bond_price(face, y2_entry,  y2_entry,         2)\n",
    "p2_exit   = bond_price(face, y2_entry,  y2_entry  + dy2,  2)\n",
    "p10_entry = bond_price(face * ratio, y10_entry, y10_entry,          10)\n",
    "p10_exit  = bond_price(face * ratio, y10_entry, y10_entry + dy10,   10)\n",
    "\n",
    "pnl_2y  = p2_exit  - p2_entry   # Long: gain if price rises (yield falls)\n",
    "pnl_10y = p10_exit - p10_entry  # Short: we pay out gains on the long → negate\n",
    "\n",
    "net_pnl = pnl_2y - pnl_10y  # short 10Y means we lose if price rises\n",
    "\n",
    "print(f'\\nScenario: 2Y −150 bps, 10Y −50 bps  (curve steepens 100 bps)')\n",
    "print(f'  2Y leg P&L  :  +EUR {pnl_2y:>12,.0f}   (long: price rises as yield falls)')\n",
    "print(f'  10Y leg P&L :  −EUR {pnl_10y:>12,.0f}   (short: price rises → we lose)')\n",
    "print(f'  Net P&L     :  +EUR {net_pnl:>12,.0f}   (steepener profits!)')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b4c5d6e7",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Visualise P&L across a range of slope changes ────────────────────────────\n",
    "# Hold the level change constant (parallel -50 bps), vary the slope (2Y vs 10Y)\n",
    "\n",
    "slope_changes = np.linspace(-200, 200, 81)  # bps, 2s10s change\n",
    "level_change  = -50 / 100 / 100             # -50 bps parallel shift\n",
    "\n",
    "pnl_steepener = []\n",
    "pnl_flattener = []\n",
    "\n",
    "for sc in slope_changes:\n",
    "    sc_dec = sc / 2 / 100   # split evenly: -sc/2 on 2Y, +sc/2 on 10Y (pure slope)\n",
    "    d2  = level_change - sc_dec\n",
    "    d10 = level_change + sc_dec\n",
    "\n",
    "    p2e  = bond_price(face, y2_entry,  y2_entry  + d2,  2)\n",
    "    p10e = bond_price(face * ratio, y10_entry, y10_entry + d10, 10)\n",
    "\n",
    "    gain2  = p2e  - p2_entry\n",
    "    gain10 = p10e - p10_entry\n",
    "\n",
    "    pnl_steepener.append(gain2 - gain10)   # long 2Y, short 10Y\n",
    "    pnl_flattener.append(-gain2 + gain10)  # short 2Y, long 10Y\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(12, 5))\n",
    "ax.plot(slope_changes, np.array(pnl_steepener) / 1e3, color='#003DA5', lw=2.5, label='Bull Steepener (long 2Y / short 10Y)')\n",
    "ax.plot(slope_changes, np.array(pnl_flattener) / 1e3, color='#AA151B', lw=2.5, label='Flattener (short 2Y / long 10Y)')\n",
    "ax.axhline(0, color='black', lw=1, alpha=0.4)\n",
    "ax.axvline(0, color='black', lw=1, ls='--', alpha=0.4)\n",
    "ax.fill_between(slope_changes, np.array(pnl_steepener) / 1e3, 0,\n",
    "                where=np.array(pnl_steepener) > 0, alpha=0.12, color='#003DA5')\n",
    "ax.fill_between(slope_changes, np.array(pnl_flattener) / 1e3, 0,\n",
    "                where=np.array(pnl_flattener) > 0, alpha=0.12, color='#AA151B')\n",
    "ax.set_xlabel('Change in 2s10s slope (bps, positive = steepening)')\n",
    "ax.set_ylabel('P&L (EUR thousands)')\n",
    "ax.set_title('Steepener vs Flattener P&L — EUR 10m DV01-Neutral Position on Bunds', pad=10)\n",
    "ax.legend(fontsize=10)\n",
    "plt.tight_layout()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c5d6e7f8",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Butterfly trade: belly vs wings ──────────────────────────────────────────\n",
    "# Long 5Y, Short 2Y and 10Y  (duration-neutral on both wings)\n",
    "# View: 5Y is cheap relative to 2Y and 10Y (butterfly should compress / go negative)\n",
    "\n",
    "y5_entry  = 2.58 / 100\n",
    "dv01_5y   = dv01(face, y5_entry, y5_entry, 5)\n",
    "\n",
    "# To be duration-neutral: n2 * DV01_2y + n10 * DV01_10y = n5 * DV01_5y\n",
    "# Assume equal weighting of wings: n2 = n10 = n_wing\n",
    "# => n_wing * (DV01_2y + DV01_10y) = n5 * DV01_5y\n",
    "n5   = 1.0   # normalised\n",
    "n_wing = n5 * dv01_5y / (dv01_2y + dv01_10y)\n",
    "\n",
    "butterfly_range = np.linspace(-80, 80, 81)  # bps change in 2s5s10s butterfly\n",
    "pnl_bf = []\n",
    "\n",
    "p5_entry = bond_price(face, y5_entry, y5_entry, 5)\n",
    "\n",
    "for bf in butterfly_range:\n",
    "    bf_dec = bf / 100 / 100  # bps → decimal change at belly\n",
    "    # Wings move symmetrically in opposite direction (curvature move)\n",
    "    d5  = -bf_dec\n",
    "    d2  = bf_dec / 2\n",
    "    d10 = bf_dec / 2\n",
    "\n",
    "    p5e  = bond_price(face, y5_entry, y5_entry + d5, 5)\n",
    "    p2e  = bond_price(face * n_wing, y2_entry, y2_entry + d2, 2)\n",
    "    p10e = bond_price(face * n_wing, y10_entry, y10_entry + d10, 10)\n",
    "\n",
    "    gain5  = p5e  - p5_entry               # long 5Y\n",
    "    gain2  = p2e  - bond_price(face * n_wing, y2_entry, y2_entry, 2)   # short 2Y\n",
    "    gain10 = p10e - bond_price(face * n_wing, y10_entry, y10_entry, 10)  # short 10Y\n",
    "\n",
    "    net = gain5 - gain2 - gain10\n",
    "    pnl_bf.append(net)\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(12, 5))\n",
    "ax.plot(butterfly_range, np.array(pnl_bf) / 1e3, color='#009246', lw=2.5,\n",
    "        label='Long belly (5Y) / Short wings (2Y + 10Y)')\n",
    "ax.axhline(0, color='black', lw=1, alpha=0.4)\n",
    "ax.axvline(0, color='black', lw=1, ls='--', alpha=0.4)\n",
    "ax.fill_between(butterfly_range, np.array(pnl_bf) / 1e3, 0,\n",
    "                where=np.array(pnl_bf) > 0, alpha=0.15, color='#009246')\n",
    "ax.fill_between(butterfly_range, np.array(pnl_bf) / 1e3, 0,\n",
    "                where=np.array(pnl_bf) < 0, alpha=0.15, color='tomato')\n",
    "ax.set_xlabel('Change in 2s5s10s butterfly (bps, positive = belly richening)')\n",
    "ax.set_ylabel('P&L (EUR thousands)')\n",
    "ax.set_title('Butterfly P&L: Long 5Y Belly, Short 2Y & 10Y Wings (Duration-Neutral)', pad=10)\n",
    "ax.legend(fontsize=10)\n",
    "plt.tight_layout()\n",
    "plt.show()\n",
    "\n",
    "print('Interpretation:')\n",
    "print('  Positive butterfly change = 5Y yields fall relative to wings → belly richens')\n",
    "print('  Long belly profits when 5Y outperforms (price rises more than wings)')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d6e7f8a9",
   "metadata": {},
   "source": [
    "---\n",
    "## 6. Duration & DV01 — Risk Management\n",
    "\n",
    "**Modified Duration** measures the percentage price change for a 1% (100 bps) parallel yield shift:\n",
    "$$P \\approx -D_{mod} \\times \\Delta y \\times P$$\n",
    "\n",
    "**DV01** (Dollar Value of 1 Basis Point) is the *cash* risk per basis point move:\n",
    "$$DV01 = D_{mod} \\times P \\times 0.0001$$\n",
    "\n",
    "**Convexity** is the second-order correction — bonds with higher convexity gain more from a yield fall than they lose from an equal yield rise (favourable asymmetry). Long-dated bonds are most convex."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e7f8a9b0",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Risk metrics for a Eurozone bond portfolio ────────────────────────────────\n",
    "portfolio = {\n",
    "    'German 2Y Bund':       dict(face=20e6,  coup=2.0/100, ytm=2.05/100, mat=2,  country='Germany'),\n",
    "    'German 10Y Bund':      dict(face=10e6,  coup=2.3/100, ytm=2.48/100, mat=10, country='Germany'),\n",
    "    'French 5Y OAT':        dict(face=15e6,  coup=2.5/100, ytm=2.45/100, mat=5,  country='France'),\n",
    "    'Italian 10Y BTP':      dict(face=8e6,   coup=3.5/100, ytm=3.80/100, mat=10, country='Italy'),\n",
    "    'Spanish 5Y Bonos':     dict(face=12e6,  coup=2.8/100, ytm=2.72/100, mat=5,  country='Spain'),\n",
    "    'Portuguese 10Y OT':    dict(face=5e6,   coup=3.2/100, ytm=3.10/100, mat=10, country='Portugal'),\n",
    "}\n",
    "\n",
    "records = []\n",
    "for name, b in portfolio.items():\n",
    "    price  = bond_price(b['face'], b['coup'], b['ytm'], b['mat'])\n",
    "    mod_d  = modified_duration(b['face'], b['coup'], b['ytm'], b['mat'])\n",
    "    dv01_v = dv01(b['face'], b['coup'], b['ytm'], b['mat'])\n",
    "    # Simple convexity approximation\n",
    "    d_up   = modified_duration(b['face'], b['coup'], b['ytm'] + 0.001, b['mat'])\n",
    "    d_down = modified_duration(b['face'], b['coup'], b['ytm'] - 0.001, b['mat'])\n",
    "    convexity = (d_down - d_up) / (2 * 0.001)\n",
    "    records.append({\n",
    "        'Bond': name,\n",
    "        'Country': b['country'],\n",
    "        'Maturity': b['mat'],\n",
    "        'YTM (%)': round(b['ytm'] * 100, 2),\n",
    "        'Price (EUR)': round(price / 1e6, 3),\n",
    "        'Mod. Duration': round(mod_d, 2),\n",
    "        'DV01 (EUR)': round(dv01_v, 0),\n",
    "        'Convexity': round(convexity, 1),\n",
    "    })\n",
    "\n",
    "df = pd.DataFrame(records).set_index('Bond')\n",
    "print('Portfolio Risk Metrics:\\n')\n",
    "print(df.to_string())\n",
    "\n",
    "total_dv01 = df['DV01 (EUR)'].sum()\n",
    "print(f'\\nPortfolio Total DV01: EUR {total_dv01:,.0f}')\n",
    "print(f'  (A parallel 100 bps rise in all yields ≈ −EUR {total_dv01*100/1e6:.2f}m)')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f8a9b0c1",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Visualise portfolio DV01 by country and maturity ─────────────────────────\n",
    "fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n",
    "\n",
    "# By country\n",
    "by_country = df.groupby('Country')['DV01 (EUR)'].sum().sort_values(ascending=True)\n",
    "colors_c = [COLORS.get(c, '#888') for c in by_country.index]\n",
    "axes[0].barh(by_country.index, by_country.values / 1e3, color=colors_c, edgecolor='white')\n",
    "axes[0].set_xlabel('DV01 (EUR thousands per bp)')\n",
    "axes[0].set_title('Portfolio DV01 by Country', pad=8)\n",
    "for i, v in enumerate(by_country.values):\n",
    "    axes[0].text(v / 1e3 + 0.05, i, f'EUR {v/1e3:.1f}k', va='center', fontsize=9)\n",
    "\n",
    "# By maturity\n",
    "df_mat = df.copy()\n",
    "df_mat['label'] = df_mat['Maturity'].astype(str) + 'Y'\n",
    "mat_map = {2: 'Short (≤3Y)', 5: 'Mid (3–7Y)', 10: 'Long (≥7Y)'}\n",
    "df_mat['bucket'] = df_mat['Maturity'].map(mat_map)\n",
    "by_bucket = df_mat.groupby('bucket')['DV01 (EUR)'].sum()\n",
    "bucket_colors = ['#4472C4', '#ED7D31', '#A9D18E']\n",
    "axes[1].pie(by_bucket.values, labels=by_bucket.index, autopct='%1.1f%%',\n",
    "            colors=bucket_colors, startangle=90, pctdistance=0.82,\n",
    "            wedgeprops=dict(edgecolor='white', linewidth=1.5))\n",
    "axes[1].set_title('DV01 Allocation by Maturity Bucket', pad=8)\n",
    "\n",
    "plt.suptitle('Eurozone Bond Portfolio — Risk Decomposition', fontsize=13, fontweight='bold', y=1.02)\n",
    "plt.tight_layout()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a9b0c1d2",
   "metadata": {},
   "source": [
    "---\n",
    "## 7. ECB Policy & Yield Curve Scenarios\n",
    "\n",
    "The ECB's key tools and their curve effects:\n",
    "\n",
    "| Tool | Mechanism | Curve Effect |\n",
    "|------|-----------|-------------|\n",
    "| **Deposit rate** | Anchors the short end | Shifts front end; re-rates 2Y/3Y |\n",
    "| **APP/PEPP** (QE) | Buys long bonds, compresses term premium | Flattens/lowers long end |\n",
    "| **TPI/OMT** (backstop) | Prevents fragmentation | Compresses peripheral spreads |\n",
    "| **Forward guidance** | Manages expectations | Locks in 1–3Y rate expectations |\n",
    "\n",
    "We examine three policy scenarios from the current easing baseline (Q1 2025):"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b0c1d2e3",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── ECB policy scenario analysis ──────────────────────────────────────────────\n",
    "base = era_2025['Germany'].copy()\n",
    "\n",
    "# Scenario A: Deeper easing — ECB cuts to 1.5%, growth disappoints\n",
    "# Short end -125 bps, long end -25 bps (bull steepener)\n",
    "scen_a = base + np.array([-1.25, -1.20, -1.10, -0.90, -0.70, -0.40, -0.20, -0.25, -0.15, -0.10, -0.05])\n",
    "\n",
    "# Scenario B: Pause with fiscal dominance — no more cuts, fiscal deficits widen\n",
    "# Short end flat, long end +50 bps (bear steepener; fiscal premium)\n",
    "scen_b = base + np.array([0.00,  0.02,  0.05,  0.10, 0.15, 0.25, 0.35, 0.50, 0.55, 0.55, 0.50])\n",
    "\n",
    "# Scenario C: Re-acceleration — ECB forced to hike again; inflation resurgence\n",
    "# Short end +200 bps, long end +80 bps (bear flattener / re-inversion)\n",
    "scen_c = base + np.array([2.00,  1.90,  1.60,  1.20, 1.00, 0.80, 0.70, 0.80, 0.80, 0.75, 0.70])\n",
    "\n",
    "scenarios = {\n",
    "    'Baseline (Q1 2025)':          (base,   '#555555', '--'),\n",
    "    'A: Deep Easing (ECB → 1.5%)': (scen_a, '#003DA5', '-'),\n",
    "    'B: Fiscal Dominance':          (scen_b, '#AA151B', '-'),\n",
    "    'C: Re-acceleration (re-hike)': (scen_c, '#D4AF37', '-'),\n",
    "}\n",
    "\n",
    "fig, axes = plt.subplots(1, 2, figsize=(16, 6))\n",
    "\n",
    "# Left: absolute yield curves\n",
    "for label, (y, col, ls) in scenarios.items():\n",
    "    m, ys = smooth_curve(MATURITIES, y)\n",
    "    axes[0].plot(m, ys, color=col, ls=ls, lw=2.5, label=label)\n",
    "    axes[0].scatter(MATURITIES, y, color=col, s=25, zorder=5)\n",
    "axes[0].axhline(0, color='black', lw=0.8, ls=':', alpha=0.4)\n",
    "axes[0].set_xlabel('Maturity (years)')\n",
    "axes[0].set_ylabel('Yield (%)')\n",
    "axes[0].set_title('German Bund — Yield Curves Under ECB Scenarios', pad=8)\n",
    "axes[0].legend(fontsize=8.5, loc='lower right')\n",
    "\n",
    "# Right: changes vs baseline in bps\n",
    "for label, (y, col, ls) in list(scenarios.items())[1:]:\n",
    "    diff = (y - base) * 100\n",
    "    m, ds = smooth_curve(MATURITIES, diff)\n",
    "    axes[1].plot(m, ds, color=col, ls=ls, lw=2.5, label=label)\n",
    "    axes[1].scatter(MATURITIES, diff, color=col, s=25, zorder=5)\n",
    "axes[1].axhline(0, color='black', lw=1, alpha=0.5)\n",
    "axes[1].set_xlabel('Maturity (years)')\n",
    "axes[1].set_ylabel('Change vs Baseline (bps)')\n",
    "axes[1].set_title('Yield Changes vs Q1 2025 Baseline', pad=8)\n",
    "axes[1].legend(fontsize=8.5)\n",
    "\n",
    "plt.suptitle('ECB Policy Scenario Analysis — German Bund Yield Curve', fontsize=13, fontweight='bold', y=1.02)\n",
    "plt.tight_layout()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c1d2e3f4",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Portfolio P&L across ECB scenarios ───────────────────────────────────────\n",
    "scenario_curves = {\n",
    "    'Baseline':           era_2025,\n",
    "    'A: Deep Easing':     {c: era_2025[c] + np.array([-1.25,-1.20,-1.10,-0.90,-0.70,-0.40,-0.20,-0.25,-0.15,-0.10,-0.05])\n",
    "                           for c in era_2025},\n",
    "    'B: Fiscal Dominance':{c: era_2025[c] + np.array([0.00,0.02,0.05,0.10,0.15,0.25,0.35,0.50,0.55,0.55,0.50])\n",
    "                           for c in era_2025},\n",
    "    'C: Re-acceleration': {c: era_2025[c] + np.array([2.00,1.90,1.60,1.20,1.00,0.80,0.70,0.80,0.80,0.75,0.70])\n",
    "                           for c in era_2025},\n",
    "}\n",
    "\n",
    "def portfolio_pnl(base_curves, shock_curves):\n",
    "    total = 0\n",
    "    for name, b in portfolio.items():\n",
    "        c = b['country']\n",
    "        ytm_base  = interp_yield(MATURITIES, base_curves[c],  b['mat'])\n",
    "        ytm_shock = interp_yield(MATURITIES, shock_curves[c], b['mat'])\n",
    "        p_base  = bond_price(b['face'], b['coup'], ytm_base,  b['mat'])\n",
    "        p_shock = bond_price(b['face'], b['coup'], ytm_shock, b['mat'])\n",
    "        total += p_shock - p_base\n",
    "    return total\n",
    "\n",
    "results = {}\n",
    "for scen, curves in scenario_curves.items():\n",
    "    if scen == 'Baseline':\n",
    "        results[scen] = 0.0\n",
    "    else:\n",
    "        results[scen] = portfolio_pnl(era_2025, curves)\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(10, 5))\n",
    "labels_sc = list(results.keys())\n",
    "values_sc = [v / 1e6 for v in results.values()]\n",
    "bar_colors = ['#888888', '#003DA5', '#AA151B', '#D4AF37']\n",
    "bars = ax.bar(labels_sc, values_sc, color=bar_colors, edgecolor='white', linewidth=1.2)\n",
    "ax.axhline(0, color='black', lw=1, alpha=0.5)\n",
    "ax.set_ylabel('P&L (EUR millions)')\n",
    "ax.set_title('Portfolio P&L Under ECB Policy Scenarios', pad=10)\n",
    "for bar, val in zip(bars, values_sc):\n",
    "    y_pos = bar.get_height() + 0.02 if val >= 0 else bar.get_height() - 0.08\n",
    "    ax.text(bar.get_x() + bar.get_width() / 2, y_pos,\n",
    "            f'EUR {val:+.2f}m', ha='center', fontsize=10, fontweight='bold')\n",
    "plt.tight_layout()\n",
    "plt.show()\n",
    "\n",
    "for sc, pnl in results.items():\n",
    "    print(f'  {sc:<30s}: EUR {pnl/1e6:+.3f}m')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d2e3f4a5",
   "metadata": {},
   "source": [
    "---\n",
    "## 8. Monte Carlo Yield Curve Simulation\n",
    "\n",
    "Rather than a small set of discrete scenarios, Monte Carlo methods generate a distribution of possible yield curve outcomes. We model yield changes as correlated random walks, where the correlation structure captures how maturities move together.\n",
    "\n",
    "A simplified approach using **PCA-style factors**:\n",
    "- **Factor 1** — Level (parallel shift): all maturities move together\n",
    "- **Factor 2** — Slope: short rates move opposite to long rates\n",
    "- **Factor 3** — Curvature: belly moves opposite to wings"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e3f4a5b6",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Monte Carlo simulation of Bund yield curve ────────────────────────────────\n",
    "np.random.seed(2025)\n",
    "N_SIM     = 2000\n",
    "N_MATS    = len(MATURITIES)\n",
    "horizon   = 1.0  # 1-year horizon\n",
    "\n",
    "# PCA factor loadings (calibrated to approximate historical Bund vol)\n",
    "# Each row is the loading of that maturity on the factor\n",
    "F_level = np.array([0.95, 0.96, 0.97, 0.98, 0.99, 1.00, 1.00, 0.99, 0.98, 0.97, 0.96])\n",
    "F_slope = np.array([-1.2, -1.1, -0.9, -0.6, -0.3, 0.0, 0.2, 0.5, 0.7, 0.8, 0.9])\n",
    "F_curve = np.array([ 0.3,  0.4,  0.5,  0.7,  0.8, 0.9, 0.7, 0.4, 0.2, 0.1, 0.0])\n",
    "\n",
    "# Normalise loadings\n",
    "F_level /= np.linalg.norm(F_level)\n",
    "F_slope /= np.linalg.norm(F_slope)\n",
    "F_curve /= np.linalg.norm(F_curve)\n",
    "\n",
    "# Factor volatilities (annualised, in percentage points)\n",
    "sigma_level = 0.85\n",
    "sigma_slope = 0.45\n",
    "sigma_curve = 0.20\n",
    "\n",
    "base_yields = era_2025['Germany'].copy()\n",
    "\n",
    "simulated_pnls = []\n",
    "simulated_final_curves = []\n",
    "\n",
    "for _ in range(N_SIM):\n",
    "    z1, z2, z3 = np.random.randn(3)\n",
    "    dy = (sigma_level * z1 * F_level\n",
    "        + sigma_slope * z2 * F_slope\n",
    "        + sigma_curve * z3 * F_curve) * np.sqrt(horizon)\n",
    "\n",
    "    shocked_yields = base_yields + dy / 100  # convert pp → decimal\n",
    "    simulated_final_curves.append(shocked_yields)\n",
    "\n",
    "    # P&L for the portfolio\n",
    "    pnl = 0\n",
    "    for name, b in portfolio.items():\n",
    "        c = b['country']\n",
    "        if c == 'Germany':\n",
    "            ytm_s = interp_yield(MATURITIES, shocked_yields, b['mat'])\n",
    "        else:\n",
    "            # Periphery: Bund shock + country-specific spread noise\n",
    "            spread_noise = np.random.randn() * 0.25 / 100  # 25 bps sd\n",
    "            ytm_s = interp_yield(MATURITIES, shocked_yields, b['mat']) + (\n",
    "                interp_yield(MATURITIES, era_2025[c], b['mat']) -\n",
    "                interp_yield(MATURITIES, base_yields, b['mat'])\n",
    "            ) + spread_noise\n",
    "        ytm_b = interp_yield(MATURITIES, era_2025[c if c != 'Germany' else c], b['mat'])\n",
    "        p_base  = bond_price(b['face'], b['coup'], ytm_b, b['mat'])\n",
    "        p_shock = bond_price(b['face'], b['coup'], ytm_s, b['mat'])\n",
    "        pnl    += p_shock - p_base\n",
    "    simulated_pnls.append(pnl)\n",
    "\n",
    "pnls = np.array(simulated_pnls) / 1e6  # EUR millions\n",
    "curves_arr = np.array(simulated_final_curves)\n",
    "\n",
    "print(f'Simulation complete: {N_SIM:,} scenarios over {horizon:.0f}-year horizon')\n",
    "print(f'Portfolio P&L Statistics (EUR millions):')\n",
    "print(f'  Mean   : {pnls.mean():+.3f}')\n",
    "print(f'  Std Dev: {pnls.std():.3f}')\n",
    "print(f'  VaR 99%: {np.percentile(pnls, 1):+.3f}')\n",
    "print(f'  ES 99% : {pnls[pnls <= np.percentile(pnls, 1)].mean():+.3f}')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f4a5b6c7",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Visualise Monte Carlo results ─────────────────────────────────────────────\n",
    "fig = plt.figure(figsize=(16, 10))\n",
    "gs = gridspec.GridSpec(2, 2, figure=fig, hspace=0.35, wspace=0.3)\n",
    "\n",
    "# 1. Simulated yield curves fan\n",
    "ax1 = fig.add_subplot(gs[0, 0])\n",
    "for curve in curves_arr[::20]:  # plot every 20th\n",
    "    m, y = smooth_curve(MATURITIES, curve)\n",
    "    ax1.plot(m, y, color='#003DA5', alpha=0.05, lw=1)\n",
    "# Percentile bands\n",
    "p10 = np.percentile(curves_arr, 10, axis=0)\n",
    "p90 = np.percentile(curves_arr, 90, axis=0)\n",
    "p50 = np.percentile(curves_arr, 50, axis=0)\n",
    "m_s, p10s = smooth_curve(MATURITIES, p10)\n",
    "_, p90s = smooth_curve(MATURITIES, p90)\n",
    "_, p50s = smooth_curve(MATURITIES, p50)\n",
    "ax1.fill_between(m_s, p10s, p90s, alpha=0.2, color='#003DA5', label='10th–90th pct')\n",
    "ax1.plot(m_s, p50s, color='#003DA5', lw=2, label='Median')\n",
    "m0, y0 = smooth_curve(MATURITIES, base_yields)\n",
    "ax1.plot(m0, y0, color='black', lw=2, ls='--', label='Baseline')\n",
    "ax1.set_title('Simulated Bund Yield Curves (1Y horizon)', pad=8)\n",
    "ax1.set_xlabel('Maturity (years)')\n",
    "ax1.set_ylabel('Yield (%)')\n",
    "ax1.legend(fontsize=9)\n",
    "\n",
    "# 2. P&L distribution\n",
    "ax2 = fig.add_subplot(gs[0, 1])\n",
    "var99 = np.percentile(pnls, 1)\n",
    "ax2.hist(pnls, bins=60, color='#003DA5', alpha=0.7, edgecolor='white', lw=0.5)\n",
    "ax2.axvline(var99, color='crimson', lw=2, ls='--', label=f'VaR 99%: EUR {var99:.2f}m')\n",
    "ax2.axvline(pnls.mean(), color='#D4AF37', lw=2, ls='--', label=f'Mean: EUR {pnls.mean():+.2f}m')\n",
    "ax2.set_title('Portfolio P&L Distribution (1Y horizon)', pad=8)\n",
    "ax2.set_xlabel('P&L (EUR millions)')\n",
    "ax2.set_ylabel('Frequency')\n",
    "ax2.legend(fontsize=9)\n",
    "\n",
    "# 3. 2s10s slope distribution\n",
    "ax3 = fig.add_subplot(gs[1, 0])\n",
    "slopes = [(interp_yield(MATURITIES, c, 10) - interp_yield(MATURITIES, c, 2)) * 100\n",
    "          for c in curves_arr]\n",
    "ax3.hist(slopes, bins=60, color='#009246', alpha=0.7, edgecolor='white', lw=0.5)\n",
    "ax3.axvline(np.mean(slopes), color='black', lw=2, ls='--',\n",
    "            label=f'Mean: {np.mean(slopes):.1f} bps')\n",
    "base_slope = (interp_yield(MATURITIES, base_yields, 10) - interp_yield(MATURITIES, base_yields, 2)) * 100\n",
    "ax3.axvline(base_slope, color='navy', lw=2, ls=':',\n",
    "            label=f'Current: {base_slope:.1f} bps')\n",
    "ax3.set_title('Simulated 2s10s Bund Slope (1Y horizon)', pad=8)\n",
    "ax3.set_xlabel('2s10s spread (bps)')\n",
    "ax3.set_ylabel('Frequency')\n",
    "ax3.legend(fontsize=9)\n",
    "\n",
    "# 4. P&L vs slope scatter\n",
    "ax4 = fig.add_subplot(gs[1, 1])\n",
    "sc = ax4.scatter(slopes, pnls, c=pnls, cmap='RdYlGn', alpha=0.4, s=10,\n",
    "                 vmin=pnls.quantile(0.05) if hasattr(pnls, 'quantile') else np.percentile(pnls, 5),\n",
    "                 vmax=np.percentile(pnls, 95))\n",
    "ax4.axhline(0, color='black', lw=1, alpha=0.5)\n",
    "ax4.axvline(base_slope, color='navy', lw=1, ls=':', alpha=0.6)\n",
    "plt.colorbar(sc, ax=ax4, label='P&L (EUR m)')\n",
    "ax4.set_xlabel('2s10s slope change (bps)')\n",
    "ax4.set_ylabel('Portfolio P&L (EUR millions)')\n",
    "ax4.set_title('P&L vs Yield Curve Slope — Scatter', pad=8)\n",
    "\n",
    "fig.suptitle('Monte Carlo Simulation — Eurozone Portfolio Risk (2,000 scenarios)',\n",
    "             fontsize=14, fontweight='bold', y=1.01)\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a5b6c7d8",
   "metadata": {},
   "source": [
    "---\n",
    "## 9. Conclusion\n",
    "\n",
    "### Key takeaways from this analysis\n",
    "\n",
    "**1. Rate regimes reshape the entire curve**  \n",
    "The Eurozone moved from deeply negative rates (2021) through an aggressive hiking cycle (2022–2023) to an easing phase (2024–2025). Each regime produced distinct curve shapes, spread levels, and trading opportunities.\n",
    "\n",
    "**2. Sovereign spreads are the heart of Eurozone fixed income**  \n",
    "The BTP-Bund spread is the primary risk gauge. ECB backstop tools (OMT, TPI) fundamentally changed the spread dynamics by removing redenomination risk — but fiscal discipline still matters for the premium.\n",
    "\n",
    "**3. Shape trades outperform pure duration bets**  \n",
    "Duration-neutral steepeners, flatteners, and butterflies allow traders to isolate specific curve views without taking on parallel-shift risk. DV01-neutral sizing is essential.\n",
    "\n",
    "**4. ECB policy drives everything**  \n",
    "The front end is almost entirely policy-driven. Long-end yields incorporate term premium, growth/inflation expectations, and fiscal concerns. The divergence between ECB intent and market pricing creates the core opportunities.\n",
    "\n",
    "**5. Risk management requires thinking in scenarios**  \n",
    "Monte Carlo simulation reveals the full distribution of outcomes and tail risks — particularly important in an environment where curve shape can move violently during ECB regime changes.\n",
    "\n",
    "---\n",
    "\n",
    "### Further exploration\n",
    "\n",
    "- `micropip.install('pandas_datareader')` or `micropip.install('yfinance')` — fetch live Eurozone yield data\n",
    "- Nelson-Siegel-Svensson parametric curve fitting\n",
    "- Relative value across the sovereign credit spectrum (AAA → BBB rated Eurozone issuers)\n",
    "- Swap spread analysis: government bonds vs EUR interest rate swaps\n",
    "- The TPI trigger framework and fragmentation risk modelling\n",
    "\n",
    "---\n",
    "*This notebook is for educational purposes. All data is synthetic/representative. Not investment advice.*"
   ]
  }
 ]
}
