{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "c0965427",
   "metadata": {},
   "source": [
    "# Synthetic Market Generation and Stress Testing\n",
    "\n",
    "## Purpose\n",
    "\n",
    "Notebook 04 found a Fama-French alpha of about 6% per year for the full momentum pipeline. Notebook 05 described the covariance structure of the stock universe. This notebook asks a different question:\n",
    "\n",
    "> If the same factor structure had appeared in a different order, would the strategy still look good?\n",
    "\n",
    "We model centered returns with a truncated PCA/SVD factor model:\n",
    "\n",
    "$$R \\approx FB^\\top+E.$$\n",
    "\n",
    "Here:\n",
    "\n",
    "- $R$ is the return panel.\n",
    "- $B$ contains the top-$k$ PCA loading vectors.\n",
    "- $F=R_cB$ contains the factor scores through time.\n",
    "- $E=R_c-FB^\\top$ contains the residual stock-level shocks.\n",
    "\n",
    "Then we generate many alternate return panels, rerun a compact version of the momentum backtest on each one, and compare the generated alphas to the real-panel alpha.\n",
    "\n",
    "We use two generators:\n",
    "1. **Block bootstrap:** resample time blocks from the observed factor/residual rows. This is the result we trust most.\n",
    "2. **Conditional VAE:** a small neural generator for factor scores. This is included as a cautionary example because it can create artificial momentum if factor scores and residuals are stitched together poorly.\n",
    "\n",
    "### Terms used in this notebook\n",
    "\n",
    "| Term | Meaning |\n",
    "|------|---------|\n",
    "| **Truncated factor model** | $R\\approx FB^\\top+E$, a low-rank approximation plus residuals |\n",
    "| **Loadings** $B$ | Stock exposures to the retained PCA directions |\n",
    "| **Factor scores** $F$ | Time series of factor realizations, one row per month |\n",
    "| **Residuals** $E$ | Return components not captured by the retained PCA factors |\n",
    "| **Block bootstrap** | Resample neighboring time blocks rather than individual months |\n",
    "| **Tail share** | Fraction of synthetic alphas greater than or equal to the real alpha |\n",
    "| **Path dependence** | Dependence on the specific order of historical months |\n",
    "\n",
    "## Outputs\n",
    "\n",
    "- `synthetic_backtest_results.csv`\n",
    "- Bootstrap and VAE alpha distribution plots\n",
    "\n",
    "## Notebook Structure\n",
    "1. [Setup and Factor Model](#setup-and-factor-model)\n",
    "2. [Backtest Baseline](#backtest-baseline)\n",
    "3. [Block-Bootstrap Generator](#block-bootstrap-generator)\n",
    "4. [Conditional VAE Generator](#conditional-vae-generator)\n",
    "5. [Results and Interpretation](#results-and-interpretation)\n",
    "6. [Conclusion](#conclusion)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "bd2a00cd",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T11:11:27.660596Z",
     "iopub.status.busy": "2026-07-31T11:11:27.659904Z",
     "iopub.status.idle": "2026-07-31T11:11:28.975793Z",
     "shell.execute_reply": "2026-07-31T11:11:28.975192Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Returns: (251, 501)  |  FF: (257, 5)\n"
     ]
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Setup and imports\n",
    "==================================\n",
    "\"\"\"\n",
    "import pandas as pd\n",
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "import seaborn as sns\n",
    "import os\n",
    "from sklearn.decomposition import PCA\n",
    "import statsmodels.api as sm\n",
    "\n",
    "RANDOM_STATE = 3\n",
    "np.random.seed(RANDOM_STATE)\n",
    "\n",
    "os.makedirs('../images/06_synthetic_markets', exist_ok=True)\n",
    "os.makedirs('../data/processed', exist_ok=True)\n",
    "\n",
    "df_returns = pd.read_csv('../data/processed/returns_monthly.csv', index_col=0, parse_dates=True)\n",
    "df_ff      = pd.read_csv('../data/raw/ff_factors.csv', index_col=0, parse_dates=True)\n",
    "\n",
    "print(f\"Returns: {df_returns.shape}  |  FF: {df_ff.shape}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "262a6ed0",
   "metadata": {},
   "source": [
    "## Setup and Factor Model\n",
    "\n",
    "We rebuild the notebook 05 factor model on a balanced panel. The steps are:\n",
    "\n",
    "1. Choose stocks with enough history and drop remaining missing rows.\n",
    "2. Center each stock's return series by subtracting its time-series mean.\n",
    "3. Estimate $k$ with the Marchenko-Pastur cutoff.\n",
    "4. Keep the top-$k$ PCA loading vectors in $B$.\n",
    "5. Compute factor scores $F=X_cB$.\n",
    "6. Compute residuals $E=X_c-FB^\\top$.\n",
    "7. Align the Fama-French factors to the same months.\n",
    "\n",
    "The centered panel is what gets decomposed. The mean vector is added back when we reconstruct synthetic returns."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b7ee87ef",
   "metadata": {},
   "source": [
    "### Small Example: What Are $R$, $F$, $B$, and $E$?\n",
    "\n",
    "Suppose we only had $T=3$ months, $N=4$ stocks, and kept $k=2$ PCA factors.\n",
    "\n",
    "A return matrix might look like this:\n",
    "\n",
    "$$\n",
    "R=\\begin{bmatrix}\n",
    "0.042 & 0.009 & 0.031 & 0.038\\\\\n",
    "-0.010 & -0.018 & -0.001 & 0.009\\\\\n",
    "-0.006 & 0.025 & 0.047 & -0.006\n",
    "\\end{bmatrix}.\n",
    "$$\n",
    "\n",
    "Rows are months; columns are stocks. First subtract the column mean vector $\\bar r$ to get $X_c=R-\\mathbf{1}\\bar r^\\top$.\n",
    "\n",
    "Now imagine PCA gives two loading vectors. Put them into\n",
    "\n",
    "$$\n",
    "B=\\begin{bmatrix}\n",
    "0.5 & 0.5\\\\\n",
    "0.5 & -0.5\\\\\n",
    "0.5 & -0.5\\\\\n",
    "0.5 & 0.5\n",
    "\\end{bmatrix}.\n",
    "$$\n",
    "\n",
    "The first column is a market-like direction: all four stocks move together. The second column is a spread direction: stocks 1 and 4 move opposite stocks 2 and 3.\n",
    "\n",
    "For three months of factor realizations, suppose\n",
    "\n",
    "$$\n",
    "F=\\begin{bmatrix}\n",
    "0.04 & 0.02\\\\\n",
    "-0.03 & 0.01\\\\\n",
    "0.01 & -0.04\n",
    "\\end{bmatrix}.\n",
    "$$\n",
    "\n",
    "The first month has a positive market shock and a positive spread shock. Multiplying $F$ by $B^\\top$ maps those factor shocks back into stock returns:\n",
    "\n",
    "$$\n",
    "FB^\\top=\\begin{bmatrix}\n",
    "0.03 & 0.01 & 0.01 & 0.03\\\\\n",
    "-0.01 & -0.02 & -0.02 & -0.01\\\\\n",
    "-0.015 & 0.025 & 0.025 & -0.015\n",
    "\\end{bmatrix}.\n",
    "$$\n",
    "\n",
    "That low-rank matrix will not match every stock return exactly, so the residual matrix stores the leftover stock-specific pieces:\n",
    "\n",
    "$$\n",
    "E=X_c-FB^\\top.\n",
    "$$\n",
    "\n",
    "The bootstrap generator resamples matching rows of $F$ and $E$. If it draws month indices $[3,1,2]$, the synthetic centered panel is\n",
    "\n",
    "$$\n",
    "X_{\\text{synth}}=\n",
    "\\begin{bmatrix}\n",
    "F_3B^\\top+E_3\\\\\n",
    "F_1B^\\top+E_1\\\\\n",
    "F_2B^\\top+E_2\n",
    "\\end{bmatrix},\n",
    "$$\n",
    "\n",
    "and the final synthetic return panel is\n",
    "\n",
    "$$R_{\\text{synth}}=X_{\\text{synth}}+\\mathbf{1}\\bar r^\\top.$$\n",
    "\n",
    "The key rule is that the same time index is used for $F$, $E$, and the benchmark factors. That keeps each synthetic month internally consistent."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "d334f4f7",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T11:11:28.977654Z",
     "iopub.status.busy": "2026-07-31T11:11:28.977462Z",
     "iopub.status.idle": "2026-07-31T11:11:29.009004Z",
     "shell.execute_reply": "2026-07-31T11:11:29.008602Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Panel (240, 394) | k=9 | F (240, 9) | E (240, 394) | FF (240, 5)\n",
      "Top-9 PCs explain 53.0% of variance\n"
     ]
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Truncated-SVD factor model:  R ≈ F Bᵀ + E   (mirrors NB05)\n",
    "==================================\n",
    "\"\"\"\n",
    "panel = df_returns.dropna(thresh=240, axis=1).dropna()   # balanced panel -> maximize T\n",
    "tickers = panel.columns.tolist()\n",
    "X = panel.values\n",
    "T, N = X.shape\n",
    "mean_r = X.mean(axis=0)\n",
    "Xc = X - mean_r\n",
    "\n",
    "pca_full = PCA(n_components=min(T, N)).fit(Xc)\n",
    "eigvals = pca_full.explained_variance_\n",
    "q = T / N\n",
    "sigma_sq = eigvals.sum() / N\n",
    "lam_plus = sigma_sq * (1 + 1 / q + 2 * np.sqrt(1 / q))\n",
    "k = max(int((eigvals > lam_plus).sum()), 1)              # Marchenko–Pastur signal count\n",
    "B = pca_full.components_[:k].T                           # (N, k) loadings\n",
    "F = Xc @ B                                               # (T, k) factor scores\n",
    "E = Xc - F @ B.T                                         # (T, N) residuals\n",
    "\n",
    "# Fama–French factors aligned to these T months (RangeIndex for positional work below)\n",
    "ff = df_ff[['Mkt-RF', 'SMB', 'HML', 'Mom', 'RF']].copy()\n",
    "ff.index = ff.index.to_period('M')\n",
    "panel_pm = panel.copy(); panel_pm.index = panel_pm.index.to_period('M')\n",
    "ff = ff.reindex(panel_pm.index).dropna().reset_index(drop=True)\n",
    "\n",
    "print(f\"Panel {X.shape} | k={k} | F {F.shape} | E {E.shape} | FF {ff.shape}\")\n",
    "print(f\"Top-{k} PCs explain {pca_full.explained_variance_ratio_[:k].sum()*100:.1f}% of variance\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e2139982",
   "metadata": {},
   "source": [
    "## Backtest Baseline\n",
    "\n",
    "For an apples-to-apples comparison we run a **compact version of the notebook 04 pipeline** on the *real* panel: re-derive the raw 12-1 momentum signal (rolling 11-month sum, shifted), form the top-decile equal-weight long-only book, and regress its excess returns on the Fama–French factors. (This simplified path — no winsorize/neutralize — is what we re-run inside every synthetic market, so real and synthetic share the exact same pipeline. Notebook 04's headline 5.97% used the full pipeline.)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "6a3dd658",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T11:11:29.011598Z",
     "iopub.status.busy": "2026-07-31T11:11:29.011414Z",
     "iopub.status.idle": "2026-07-31T11:11:29.136737Z",
     "shell.execute_reply": "2026-07-31T11:11:29.136054Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Real-panel raw-momentum alpha: 4.49%/yr  (t = 2.59)\n",
      "(Notebook 04 full-pipeline headline: 5.97%/yr, t = 3.98)\n"
     ]
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Backtest primitives + real-panel baseline alpha\n",
    "==================================\n",
    "\"\"\"\n",
    "def block_indices(T, L=15, rng=np.random):\n",
    "    \"\"\"Stationary block bootstrap: T time indices resampled in variable-length blocks.\"\"\"\n",
    "    idx = []\n",
    "    while len(idx) < T:\n",
    "        start = rng.randint(T)\n",
    "        blen = rng.geometric(1.0 / L)\n",
    "        idx.extend((start + np.arange(blen)) % T)\n",
    "    return np.array(idx[:T])\n",
    "\n",
    "def momentum_signal(ret_df):\n",
    "    \"\"\"12-1 momentum: rolling 11-month sum, shifted to avoid look-ahead.\"\"\"\n",
    "    return ret_df.rolling(11).sum().shift(1)\n",
    "\n",
    "def decile_long_returns(signal_df, ret_df, decile=0.1):\n",
    "    \"\"\"Top-decile equal-weight long-only monthly returns (signal at t, return at t+1).\"\"\"\n",
    "    out, ix = [], []\n",
    "    for i in range(len(signal_df) - 1):\n",
    "        s = signal_df.iloc[i].dropna()\n",
    "        if len(s) < 50:\n",
    "            continue\n",
    "        n = max(int(len(s) * decile), 1)\n",
    "        long = s.sort_values(ascending=False).head(n).index\n",
    "        out.append(ret_df.iloc[i + 1][long].mean())\n",
    "        ix.append(i + 1)\n",
    "    return pd.Series(out, index=ix, dtype=float)\n",
    "\n",
    "def ff_alpha(long_ret, ff_df):\n",
    "    \"\"\"Annualized FF 4-factor alpha (and t-stat) for a long-only return series.\"\"\"\n",
    "    a = pd.concat([long_ret.rename('r'), ff_df[['Mkt-RF', 'SMB', 'HML', 'Mom', 'RF']]], axis=1).dropna()\n",
    "    y = a['r'] - a['RF']\n",
    "    Xf = sm.add_constant(a[['Mkt-RF', 'SMB', 'HML', 'Mom']])\n",
    "    m = sm.OLS(y.values, Xf.values).fit()\n",
    "    return m.params[0] * 12.0, m.tvalues[0]\n",
    "\n",
    "# Real-panel baseline (apples-to-apples with the synthetic loop below)\n",
    "ret_real = pd.DataFrame(X, columns=tickers)\n",
    "real_alpha, real_t = ff_alpha(decile_long_returns(momentum_signal(ret_real), ret_real), ff)\n",
    "print(f\"Real-panel raw-momentum alpha: {real_alpha*100:.2f}%/yr  (t = {real_t:.2f})\")\n",
    "print(f\"(Notebook 04 full-pipeline headline: 5.97%/yr, t = 3.98)\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cbdb1675",
   "metadata": {},
   "source": [
    "## Block-Bootstrap Generator\n",
    "\n",
    "The block bootstrap resamples time indices in short runs rather than one month at a time. With $T\\approx240$, the average block length is about $\\sqrt{T}\\approx15$ months.\n",
    "\n",
    "For a generated index sequence `idx`, we reconstruct synthetic returns as\n",
    "\n",
    "$$R_{\\text{synth},t}=F_{\\mathrm{idx}_t}B^\\top+E_{\\mathrm{idx}_t}+\\bar r.$$\n",
    "\n",
    "We use the same `idx` for the Fama-French factor rows. This matters: if the stock return panel is pretending that month 37 came next, the benchmark factor panel should also use month 37.\n",
    "\n",
    "This generator tests **path dependence**. It asks whether the strategy needed the exact historical order of regimes, crashes, rebounds, and quiet periods."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "2aa45300",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T11:11:29.138516Z",
     "iopub.status.busy": "2026-07-31T11:11:29.138286Z",
     "iopub.status.idle": "2026-07-31T11:12:06.129556Z",
     "shell.execute_reply": "2026-07-31T11:12:06.128901Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  50/300 paths done\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  100/300 paths done\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  150/300 paths done\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  200/300 paths done\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  250/300 paths done\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  300/300 paths done\n",
      "\n",
      "Bootstrap: mean synth alpha 4.85%/yr | P(>= real 4.49%) = 56.3%\n"
     ]
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Block bootstrap: 300 synthetic markets\n",
    "==================================\n",
    "\"\"\"\n",
    "N_PATHS = 300\n",
    "L = 15\n",
    "rng = np.random.RandomState(RANDOM_STATE)\n",
    "boot_alphas = []\n",
    "for p in range(N_PATHS):\n",
    "    idx = block_indices(T, L, rng)\n",
    "    R_synth = F[idx] @ B.T + E[idx] + mean_r\n",
    "    ret_s = pd.DataFrame(R_synth, columns=tickers)\n",
    "    long_s = decile_long_returns(momentum_signal(ret_s), ret_s)\n",
    "    a, _ = ff_alpha(long_s, ff.iloc[idx].reset_index(drop=True))\n",
    "    boot_alphas.append(a)\n",
    "    if (p + 1) % 50 == 0:\n",
    "        print(f\"  {p+1}/{N_PATHS} paths done\")\n",
    "boot_alphas = np.array(boot_alphas)\n",
    "p_boot = np.mean(boot_alphas >= real_alpha)\n",
    "print(f\"\\nBootstrap: mean synth alpha {boot_alphas.mean()*100:.2f}%/yr | \"\n",
    "      f\"P(>= real {real_alpha*100:.2f}%) = {p_boot*100:.1f}%\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "eb0bb233",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T11:12:06.131528Z",
     "iopub.status.busy": "2026-07-31T11:12:06.131300Z",
     "iopub.status.idle": "2026-07-31T11:12:06.745422Z",
     "shell.execute_reply": "2026-07-31T11:12:06.744889Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 88788 characters]",
      "text/plain": [
       "<Figure size 1400x500 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Bootstrap: one path + alpha distribution\n",
    "==================================\n",
    "\"\"\"\n",
    "fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n",
    "\n",
    "ax = axes[0]\n",
    "ax.plot((1 + X.mean(axis=1)).cumprod() - 1, color='steelblue', label='real')\n",
    "idx0 = block_indices(T, L, rng)\n",
    "R0 = F[idx0] @ B.T + E[idx0] + mean_r\n",
    "ax.plot((1 + R0.mean(axis=1)).cumprod() - 1, color='coral', alpha=0.85, label='one synthetic')\n",
    "ax.set_title('Real vs one synthetic market (equal-weight cumulative)')\n",
    "ax.set_xlabel('synthetic month'); ax.legend(); ax.grid(alpha=0.3)\n",
    "\n",
    "ax = axes[1]\n",
    "ax.hist(boot_alphas * 100, bins=30, color='steelblue', alpha=0.7)\n",
    "ax.axvline(real_alpha * 100, color='coral', linewidth=2, label=f'real {real_alpha*100:.2f}%')\n",
    "ax.set_xlabel('Annualized FF alpha (%)'); ax.set_ylabel('# synthetic markets')\n",
    "ax.set_title(f'Block-bootstrap alpha (p = {p_boot*100:.1f}%)')\n",
    "ax.legend(); ax.grid(alpha=0.3)\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig('../images/06_synthetic_markets/bootstrap_alpha.png', dpi=150, bbox_inches='tight')\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "17f709e9",
   "metadata": {},
   "source": [
    "## Conditional VAE Generator\n",
    "\n",
    "The VAE section is intentionally more cautious. A small conditional variational autoencoder tries to model factor-score transitions:\n",
    "\n",
    "$$f_{t-1}\\rightarrow f_t.$$\n",
    "\n",
    "The encoder maps the previous and current factor score to latent parameters, samples a latent vector $z$, and the decoder tries to reconstruct the next factor score. After training, we sample new factor paths one step at a time.\n",
    "\n",
    "This is interesting, but it creates a serious validation problem. To reconstruct stock returns we still need residual rows $E_t$. If generated factor scores are paired with independently sampled residuals, we may create combinations that never existed in the real data. That can accidentally create cross-sectional persistence, which is exactly what a momentum strategy profits from.\n",
    "\n",
    "So the VAE is included as a warning: generative finance models can look sophisticated while quietly injecting the signal being tested."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "844c73c3",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T11:12:06.747247Z",
     "iopub.status.busy": "2026-07-31T11:12:06.747003Z",
     "iopub.status.idle": "2026-07-31T11:12:09.315924Z",
     "shell.execute_reply": "2026-07-31T11:12:09.315412Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "VAE trained | recon 0.0096 | kl 7.69\n"
     ]
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Conditional VAE on the factor scores F (autoregressive)\n",
    "==================================\n",
    "\"\"\"\n",
    "import torch\n",
    "import torch.nn as nn\n",
    "torch.manual_seed(RANDOM_STATE)\n",
    "\n",
    "Ft = torch.tensor(F, dtype=torch.float32)        # (T, k)\n",
    "cond, target = Ft[:-1], Ft[1:]                   # f_{t-1} -> f_t\n",
    "d = 4                                            # latent dim\n",
    "\n",
    "class CVAE(nn.Module):\n",
    "    def __init__(self, k, d):\n",
    "        super().__init__()\n",
    "        self.enc = nn.Sequential(nn.Linear(2*k, 32), nn.ReLU(), nn.Linear(32, 2*d))\n",
    "        self.dec = nn.Sequential(nn.Linear(k+d, 32), nn.ReLU(), nn.Linear(32, k))\n",
    "        self.d = d\n",
    "    def encode(self, c, x):\n",
    "        h = self.enc(torch.cat([c, x], -1)); return h[..., :self.d], h[..., self.d:]\n",
    "    def decode(self, c, z):\n",
    "        return self.dec(torch.cat([c, z], -1))\n",
    "\n",
    "model = CVAE(k, d)\n",
    "opt = torch.optim.Adam(model.parameters(), lr=1e-2)\n",
    "for epoch in range(300):\n",
    "    mu, logvar = model.encode(cond, target)\n",
    "    z = mu + torch.randn_like(mu) * torch.exp(0.5 * logvar)\n",
    "    recon = model.decode(cond, z)\n",
    "    recon_loss = ((recon - target) ** 2).mean()\n",
    "    kl = -0.5 * (1 + logvar - mu**2 - torch.exp(logvar)).sum(-1).mean()\n",
    "    loss = recon_loss + 1e-3 * kl\n",
    "    opt.zero_grad(); loss.backward(); opt.step()\n",
    "print(f\"VAE trained | recon {recon_loss.item():.4f} | kl {kl.item():.2f}\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "bf811200",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T11:12:09.318171Z",
     "iopub.status.busy": "2026-07-31T11:12:09.317809Z",
     "iopub.status.idle": "2026-07-31T11:12:51.102586Z",
     "shell.execute_reply": "2026-07-31T11:12:51.101852Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  VAE 50/300 paths done\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  VAE 100/300 paths done\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  VAE 150/300 paths done\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  VAE 200/300 paths done\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  VAE 250/300 paths done\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  VAE 300/300 paths done\n",
      "\n",
      "VAE: mean synth alpha 14.46%/yr | P(>= real 4.49%) = 99.7%\n"
     ]
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "VAE: ancestral-sample 300 synthetic markets, run the backtest\n",
    "==================================\n",
    "\"\"\"\n",
    "def sample_path(model, T, k, d, f0):\n",
    "    f = [f0]\n",
    "    with torch.no_grad():\n",
    "        for _ in range(T - 1):\n",
    "            z = torch.randn(d)\n",
    "            f.append(model.decode(f[-1], z))\n",
    "    return torch.stack(f).numpy()\n",
    "\n",
    "vae_alphas = []\n",
    "for p in range(N_PATHS):\n",
    "    F_synth = sample_path(model, T, k, d, Ft[0])              # (T, k)\n",
    "    idx = rng.randint(0, T, size=T)                           # iid residual draw\n",
    "    R_synth = F_synth @ B.T + E[idx] + mean_r\n",
    "    ret_s = pd.DataFrame(R_synth, columns=tickers)\n",
    "    long_s = decile_long_returns(momentum_signal(ret_s), ret_s)\n",
    "    a, _ = ff_alpha(long_s, ff.iloc[idx].reset_index(drop=True))\n",
    "    vae_alphas.append(a)\n",
    "    if (p + 1) % 50 == 0:\n",
    "        print(f\"  VAE {p+1}/{N_PATHS} paths done\")\n",
    "vae_alphas = np.array(vae_alphas)\n",
    "p_vae = np.mean(vae_alphas >= real_alpha)\n",
    "print(f\"\\nVAE: mean synth alpha {vae_alphas.mean()*100:.2f}%/yr | \"\n",
    "      f\"P(>= real {real_alpha*100:.2f}%) = {p_vae*100:.1f}%\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "diag-vae",
   "metadata": {},
   "source": [
    "### Why the VAE Overstates the Alpha\n",
    "\n",
    "The VAE synthetic markets produce alphas well above the real raw-momentum baseline. I would not treat that as evidence of a better stress test. It is more likely an artifact of how the synthetic return rows are assembled.\n",
    "\n",
    "| reconstruction of $R$ | mean synthetic alpha |\n",
    "|---|---:|\n",
    "| $F[\\mathrm{idx}]B^\\top+E[\\mathrm{idx}]$ with the **same** time index | about 5% |\n",
    "| generated $\\hat F$ plus independently resampled $E$ | often 10-25% |\n",
    "\n",
    "The second construction breaks the observed relationship between common factor shocks and stock-specific residual shocks. That can manufacture momentum-like structure.\n",
    "\n",
    "The lesson is practical: when stress-testing a signal, preserving the joint distribution matters more than using a fancier generator. For this project, the row/block bootstrap is the result to rely on."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b3574655",
   "metadata": {},
   "source": [
    "## Results and Interpretation\n",
    "\n",
    "The plot compares the real-panel alpha to two synthetic-alpha distributions.\n",
    "\n",
    "The number labeled as a bootstrap tail share is the fraction of synthetic markets with alpha greater than or equal to the real-panel alpha. A value near 50% means the real result is close to the middle of the synthetic distribution. A value near 5% would mean the real history was unusually favorable compared with the generated histories.\n",
    "\n",
    "Because the bootstrap keeps the same loadings $B$ and resamples observed factor/residual rows, it does **not** test whether momentum exists from first principles. It tests whether the observed alpha is unusually dependent on the order in which historical months happened."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "43719fe4",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T11:12:51.104749Z",
     "iopub.status.busy": "2026-07-31T11:12:51.104514Z",
     "iopub.status.idle": "2026-07-31T11:12:51.528711Z",
     "shell.execute_reply": "2026-07-31T11:12:51.528086Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 44044 characters]",
      "text/plain": [
       "<Figure size 1000x500 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Real alpha            : 4.49%/yr  (t = 2.59)\n",
      "Block bootstrap (300) : mean 4.85% | median 4.81% | p = 56.3%\n",
      "VAE            (300) : mean 14.46% | median 14.12% | p = 99.7%\n"
     ]
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Compare bootstrap vs VAE alpha distributions\n",
    "==================================\n",
    "\"\"\"\n",
    "fig, ax = plt.subplots(figsize=(10, 5))\n",
    "ax.hist(boot_alphas * 100, bins=30, alpha=0.6, color='steelblue',\n",
    "        label=f'block bootstrap (p = {p_boot*100:.1f}%)')\n",
    "ax.hist(vae_alphas * 100, bins=30, alpha=0.6, color='coral',\n",
    "        label=f'VAE (p = {p_vae*100:.1f}%)')\n",
    "ax.axvline(real_alpha * 100, color='black', linewidth=2, linestyle='--',\n",
    "           label=f'real {real_alpha*100:.2f}%')\n",
    "ax.set_xlabel('Annualized FF alpha (%)'); ax.set_ylabel('# synthetic markets')\n",
    "ax.set_title('Synthetic-market alpha: real vs generated histories')\n",
    "ax.legend(); ax.grid(alpha=0.3)\n",
    "plt.tight_layout()\n",
    "plt.savefig('../images/06_synthetic_markets/vae_vs_bootstrap.png', dpi=150, bbox_inches='tight')\n",
    "plt.show()\n",
    "\n",
    "print(f\"Real alpha            : {real_alpha*100:.2f}%/yr  (t = {real_t:.2f})\")\n",
    "print(f\"Block bootstrap (300) : mean {boot_alphas.mean()*100:.2f}% | \"\n",
    "      f\"median {np.median(boot_alphas)*100:.2f}% | p = {p_boot*100:.1f}%\")\n",
    "print(f\"VAE            (300) : mean {vae_alphas.mean()*100:.2f}% | \"\n",
    "      f\"median {np.median(vae_alphas)*100:.2f}% | p = {p_vae*100:.1f}%\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "id": "a0fe42d5",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T11:12:51.530423Z",
     "iopub.status.busy": "2026-07-31T11:12:51.530244Z",
     "iopub.status.idle": "2026-07-31T11:12:51.536802Z",
     "shell.execute_reply": "2026-07-31T11:12:51.536199Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Saved ../data/processed/synthetic_backtest_results.csv\n"
     ]
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Save synthetic-market results\n",
    "==================================\n",
    "\"\"\"\n",
    "pd.DataFrame({'bootstrap_alpha': boot_alphas, 'vae_alpha': vae_alphas}).to_csv(\n",
    "    '../data/processed/synthetic_backtest_results.csv', index=False)\n",
    "print(\"Saved ../data/processed/synthetic_backtest_results.csv\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "610b2bc9",
   "metadata": {},
   "source": [
    "## Conclusion\n",
    "\n",
    "The stress test asks whether the raw-momentum alpha is a path-dependent fluke. The block bootstrap result says it probably is not: the real-panel alpha sits near the middle of the synthetic distribution rather than in an extreme right tail.\n",
    "\n",
    "- **Block bootstrap:** trustworthy for this purpose because it resamples matched rows of factor scores, residuals, and benchmark factors. It preserves the joint structure of the return panel while changing the order of market history.\n",
    "- **Conditional VAE:** useful as a cautionary example, not as the headline result. Generating factor paths and attaching independent residuals can create artificial momentum.\n",
    "\n",
    "The honest scope is narrower than “we proved momentum works.” The bootstrap keeps the same factor structure and loadings, so it asks whether the strategy is unusually lucky conditional on that structure. It does not test a null world where the momentum premium has been removed.\n",
    "\n",
    "The next steps beyond this project would be a survivorship-free universe, real fundamental data for value and quality, and a cleaner null model that explicitly removes momentum before running the stress test."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": ".venv",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.14.6"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
