{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "ba32eb4a",
   "metadata": {},
   "source": [
    "# Backtest and Performance\n",
    "\n",
    "## Purpose\n",
    "\n",
    "This is the main portfolio notebook. We take the sector-neutralized momentum signal from notebook 03, form portfolio weights, subtract transaction costs, and measure the result.\n",
    "\n",
    "A portfolio is a weight vector. If $w_t$ is the portfolio chosen at rebalance date $t$, and $r_{t+1}$ is the next month's return vector, then the portfolio return is\n",
    "\n",
    "$$r_{p,t+1}=w_t^\\top r_{t+1}.$$\n",
    "\n",
    "The goals are:\n",
    "1. Build top-decile long-only and long-short portfolios from the momentum signal.\n",
    "2. Track one-way turnover explicitly: $\\tfrac{1}{2}\\|w_t-w_{t-1}\\|_1$.\n",
    "3. Subtract transaction costs proportional to turnover.\n",
    "4. Compute Sharpe, Sortino, max drawdown, Calmar, and active return versus the equal-weight universe.\n",
    "5. Run walk-forward checks across 5-year windows.\n",
    "6. Estimate Fama-French alpha with an OLS regression.\n",
    "7. Stress-test survivorship bias by asking how much missing-name drag would erase the alpha.\n",
    "\n",
    "### Terms used in this notebook\n",
    "\n",
    "| Term | Meaning |\n",
    "|------|---------|\n",
    "| **Long** | Holding a stock with positive weight $w_i>0$ |\n",
    "| **Short** | Holding a negative weight $w_i<0$ |\n",
    "| **Long-only portfolio** | Weight vector with $w_i\\ge 0$ and $\\sum_i w_i=1$ |\n",
    "| **Long-short portfolio** | Long winners and short losers; roughly dollar-neutral with $\\sum_i w_i=0$ |\n",
    "| **Decile** | Top or bottom 10% of stocks by signal rank |\n",
    "| **Basis point (bp)** | 1 bp = 0.01%; 5 bps = 0.05% |\n",
    "| **Turnover** | One-way turnover $\\tfrac{1}{2}\\|w_t-w_{t-1}\\|_1$ |\n",
    "| **Transaction cost** | Cost rate times one-way turnover |\n",
    "| **Sharpe ratio** | Annualized mean return divided by annualized volatility |\n",
    "| **Sortino ratio** | Similar to Sharpe, but only downside moves enter the denominator |\n",
    "| **Max drawdown** | Worst percentage decline from a previous wealth peak |\n",
    "| **Alpha** | Regression intercept after controlling for benchmark factors |\n",
    "| **Beta** | Regression loading on a benchmark factor |\n",
    "| **Fama-French factors** | Standard market, size, value, and momentum benchmark returns |\n",
    "| **Active return / IR** | Portfolio return minus benchmark return; IR = active return / tracking error |\n",
    "| **Survivorship bias** ↻ | Tested here with synthetic return drag |\n",
    "\n",
    "## Outputs\n",
    "\n",
    "Equity curves, drawdowns, performance tables, Fama-French regressions, survivorship sensitivity tables, and `backtest_returns.csv` for the risk notebook.\n",
    "\n",
    "## Notebook Structure\n",
    "1. [Setup and Imports](#setup-and-imports)\n",
    "2. [Load Data and Benchmark Factors](#load-data-and-benchmark-factors)\n",
    "3. [Portfolio Formation](#portfolio-formation)\n",
    "4. [Turnover and Transaction Costs](#turnover-and-transaction-costs)\n",
    "5. [Performance Metrics](#performance-metrics)\n",
    "6. [Walk-Forward Analysis](#walk-forward-analysis)\n",
    "7. [Fama–French Alpha](#famafrench-alpha)\n",
    "8. [Survivorship Bias Sensitivity](#survivorship-bias-sensitivity)\n",
    "9. [Conclusion](#conclusion)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "b2db11de",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T12:20:19.758791Z",
     "iopub.status.busy": "2026-07-31T12:20:19.757884Z",
     "iopub.status.idle": "2026-07-31T12:20:21.098020Z",
     "shell.execute_reply": "2026-07-31T12:20:21.097429Z"
    }
   },
   "outputs": [],
   "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",
    "\n",
    "pd.set_option('display.max_columns', None)\n",
    "pd.set_option('display.max_rows', 100)\n",
    "\n",
    "palette = ['steelblue', 'coral', 'seagreen']\n",
    "\n",
    "os.makedirs('../data/processed', exist_ok=True)\n",
    "os.makedirs('../images/04_backtest', exist_ok=True)\n",
    "\n",
    "RANDOM_STATE = 3\n",
    "TRANSACTION_COST_BPS = 5  # 5 bps per unit of one-way turnover\n",
    "REBALANCE_FREQ = 'ME'     # Monthly"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "293597e5",
   "metadata": {},
   "source": [
    "## Load Data and Benchmark Factors"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "9ffbee22",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T12:20:21.100126Z",
     "iopub.status.busy": "2026-07-31T12:20:21.099825Z",
     "iopub.status.idle": "2026-07-31T12:20:21.176089Z",
     "shell.execute_reply": "2026-07-31T12:20:21.175603Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Momentum: (251, 501)\n",
      "Returns:   (251, 501)\n"
     ]
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Load momentum signal and returns\n",
    "==================================\n",
    "\"\"\"\n",
    "df_momentum = pd.read_csv('../data/processed/momentum_signal.csv', index_col=0, parse_dates=True)\n",
    "df_returns = pd.read_csv('../data/processed/returns_monthly.csv', index_col=0, parse_dates=True)\n",
    "\n",
    "print(f\"Momentum: {df_momentum.shape}\")\n",
    "print(f\"Returns:   {df_returns.shape}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "05c2fc00",
   "metadata": {},
   "source": [
    "## Benchmark Factors and Alpha\n",
    "\n",
    "A 20% portfolio return sounds good, but it may not be stock-picking skill. If the market returned 18% and the portfolio had high market beta, most of the return may be ordinary market exposure.\n",
    "\n",
    "To separate those effects, we use the Fama-French benchmark factors. The regression later in this notebook is:\n",
    "\n",
    "$$r_p - r_f = \\alpha + \\beta_1\\mathrm{MKT} + \\beta_2\\mathrm{SMB} + \\beta_3\\mathrm{HML} + \\beta_4\\mathrm{MOM} + \\varepsilon.$$\n",
    "\n",
    "| Factor | Symbol | What it captures |\n",
    "|--------|--------|-----------------|\n",
    "| **Market** | MKT-RF | Market excess return above the risk-free rate |\n",
    "| **Size** | SMB | Small-minus-big stock return spread |\n",
    "| **Value** | HML | High-minus-low book-to-market return spread |\n",
    "| **Momentum** | MOM | Winner-minus-loser momentum factor |\n",
    "| **Risk-free rate** | RF | Monthly T-bill rate used to compute excess returns |\n",
    "\n",
    "The betas measure exposure to known return drivers. The alpha is the intercept: the average monthly return left over after those exposures are accounted for. A positive alpha with a large t-statistic is evidence that the strategy is doing more than taking standard factor risk.\n",
    "\n",
    "These factors are loaded from the Kenneth French Data Library, using the local cache when available."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "5bfbd826",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T12:20:21.177834Z",
     "iopub.status.busy": "2026-07-31T12:20:21.177665Z",
     "iopub.status.idle": "2026-07-31T12:20:21.192027Z",
     "shell.execute_reply": "2026-07-31T12:20:21.191419Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "FF factors: (257, 5)\n",
      "Columns: ['Mkt-RF', 'SMB', 'HML', 'RF', 'Mom']\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>Mkt-RF</th>\n",
       "      <th>SMB</th>\n",
       "      <th>HML</th>\n",
       "      <th>RF</th>\n",
       "      <th>Mom</th>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>Date</th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>2005-01-01</th>\n",
       "      <td>-0.0275</td>\n",
       "      <td>-0.0166</td>\n",
       "      <td>0.0206</td>\n",
       "      <td>0.0016</td>\n",
       "      <td>0.0312</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2005-02-01</th>\n",
       "      <td>0.0188</td>\n",
       "      <td>-0.0057</td>\n",
       "      <td>0.0141</td>\n",
       "      <td>0.0016</td>\n",
       "      <td>0.0343</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2005-03-01</th>\n",
       "      <td>-0.0194</td>\n",
       "      <td>-0.0141</td>\n",
       "      <td>0.0207</td>\n",
       "      <td>0.0021</td>\n",
       "      <td>0.0043</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2005-04-01</th>\n",
       "      <td>-0.0261</td>\n",
       "      <td>-0.0393</td>\n",
       "      <td>0.0005</td>\n",
       "      <td>0.0021</td>\n",
       "      <td>-0.0070</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2005-05-01</th>\n",
       "      <td>0.0365</td>\n",
       "      <td>0.0286</td>\n",
       "      <td>-0.0058</td>\n",
       "      <td>0.0024</td>\n",
       "      <td>0.0037</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "            Mkt-RF     SMB     HML      RF     Mom\n",
       "Date                                              \n",
       "2005-01-01 -0.0275 -0.0166  0.0206  0.0016  0.0312\n",
       "2005-02-01  0.0188 -0.0057  0.0141  0.0016  0.0343\n",
       "2005-03-01 -0.0194 -0.0141  0.0207  0.0021  0.0043\n",
       "2005-04-01 -0.0261 -0.0393  0.0005  0.0021 -0.0070\n",
       "2005-05-01  0.0365  0.0286 -0.0058  0.0024  0.0037"
      ]
     },
     "execution_count": 3,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Download Fama-French 3-factor + Momentum from Ken French data library\n",
    "==================================\n",
    "\"\"\"\n",
    "ff_path = '../data/raw/ff_factors.csv'\n",
    "\n",
    "if os.path.exists(ff_path):\n",
    "    df_ff = pd.read_csv(ff_path, index_col=0, parse_dates=True)\n",
    "else:\n",
    "    import pandas_datareader as pdr\n",
    "\n",
    "    print(\"Downloading Fama-French factors...\")\n",
    "    # 3-factor monthly\n",
    "    df_ff3 = pdr.famafrench.FamaFrenchReader('F-F_Research_Data_Factors', start='2005-01-01').read()[0]\n",
    "    df_ff3.index = df_ff3.index.to_timestamp()\n",
    "    df_ff3 = df_ff3 / 100  # Convert from percent to decimal\n",
    "    \n",
    "    # Momentum monthly\n",
    "    df_mom = pdr.famafrench.FamaFrenchReader('F-F_Momentum_Factor', start='2005-01-01').read()[0]\n",
    "    df_mom.index = df_mom.index.to_timestamp()\n",
    "    df_mom = df_mom / 100\n",
    "    \n",
    "    df_ff = df_ff3.join(df_mom)\n",
    "    df_ff.to_csv(ff_path)\n",
    "    print(f\"Saved to {ff_path}\")\n",
    "\n",
    "print(f\"FF factors: {df_ff.shape}\")\n",
    "print(f\"Columns: {list(df_ff.columns)}\")\n",
    "df_ff.head()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "fcbaf1c1",
   "metadata": {},
   "source": [
    "## Portfolio Formation\n",
    "\n",
    "At each rebalance date, we rank stocks by the momentum signal (the vector $c_t$) and form two portfolios:\n",
    "- **Long-only**: equal-weight the top decile (top 10% of stocks by momentum score). This is a sparse weight vector with $w_i = 1/k$ for the top $k$ stocks and $w_i = 0$ for the rest.\n",
    "- **Long-short (comparison)**: long the top decile, short the bottom decile, equal-weighted on each side with $\\sum w_i = 0$ (dollar-neutral).\n",
    "\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "8691aa8a",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T12:20:21.193875Z",
     "iopub.status.busy": "2026-07-31T12:20:21.193696Z",
     "iopub.status.idle": "2026-07-31T12:20:21.438807Z",
     "shell.execute_reply": "2026-07-31T12:20:21.438161Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Backtest period: 2006-02-28 00:00:00 to 2025-12-31 00:00:00\n",
      "Number of rebalances: 239\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>long</th>\n",
       "      <th>short</th>\n",
       "      <th>ls</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>count</th>\n",
       "      <td>239.0000</td>\n",
       "      <td>239.0000</td>\n",
       "      <td>239.0000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>mean</th>\n",
       "      <td>0.0164</td>\n",
       "      <td>0.0168</td>\n",
       "      <td>-0.0004</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>std</th>\n",
       "      <td>0.0560</td>\n",
       "      <td>0.0700</td>\n",
       "      <td>0.0479</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>min</th>\n",
       "      <td>-0.1796</td>\n",
       "      <td>-0.2209</td>\n",
       "      <td>-0.4085</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>25%</th>\n",
       "      <td>-0.0116</td>\n",
       "      <td>-0.0211</td>\n",
       "      <td>-0.0214</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>50%</th>\n",
       "      <td>0.0176</td>\n",
       "      <td>0.0142</td>\n",
       "      <td>0.0023</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>75%</th>\n",
       "      <td>0.0497</td>\n",
       "      <td>0.0486</td>\n",
       "      <td>0.0250</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>max</th>\n",
       "      <td>0.1727</td>\n",
       "      <td>0.4796</td>\n",
       "      <td>0.1008</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "           long     short        ls\n",
       "count  239.0000  239.0000  239.0000\n",
       "mean     0.0164    0.0168   -0.0004\n",
       "std      0.0560    0.0700    0.0479\n",
       "min     -0.1796   -0.2209   -0.4085\n",
       "25%     -0.0116   -0.0211   -0.0214\n",
       "50%      0.0176    0.0142    0.0023\n",
       "75%      0.0497    0.0486    0.0250\n",
       "max      0.1727    0.4796    0.1008"
      ]
     },
     "execution_count": 4,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Form top-decile long and long-short portfolios\n",
    "==================================\n",
    "\n",
    "At each rebalance date:\n",
    "  - rank stocks by momentum score\n",
    "  - long the top decile, equal-weighted\n",
    "  - short the bottom decile, equal-weighted (for L/S portfolio)\n",
    "\n",
    "We trade on t+1 to avoid look-ahead bias: signal computed at month-end t,\n",
    "returns realized over month t+1.\n",
    "\"\"\"\n",
    "def form_decile_portfolios(signal_df, return_df, decile=0.1):\n",
    "    \"\"\"Form long (top decile) and short (bottom decile) portfolios.\"\"\"\n",
    "    common_dates = signal_df.index.intersection(return_df.index)\n",
    "    common_tickers = signal_df.columns.intersection(return_df.columns)\n",
    "    \n",
    "    signal_df = signal_df.loc[common_dates, common_tickers]\n",
    "    return_df = return_df.loc[common_dates, common_tickers]\n",
    "    \n",
    "    long_returns = []\n",
    "    short_returns = []\n",
    "    ls_returns = []\n",
    "    long_holdings = []\n",
    "    short_holdings = []\n",
    "    rebalance_dates = []\n",
    "    \n",
    "    for i in range(len(common_dates) - 1):\n",
    "        date = common_dates[i]\n",
    "        next_date = common_dates[i + 1]\n",
    "        \n",
    "        scores = signal_df.loc[date].dropna()\n",
    "        if len(scores) < 50:\n",
    "            continue\n",
    "        \n",
    "        n_long = max(int(len(scores) * decile), 1)\n",
    "        n_short = max(int(len(scores) * decile), 1)\n",
    "        \n",
    "        ranked = scores.sort_values(ascending=False)\n",
    "        long_tickers = ranked.head(n_long).index.tolist()\n",
    "        short_tickers = ranked.tail(n_short).index.tolist()\n",
    "        \n",
    "        # Returns realized over next month\n",
    "        next_rets = return_df.loc[next_date]\n",
    "        \n",
    "        long_ret = next_rets[long_tickers].mean()\n",
    "        short_ret = next_rets[short_tickers].mean()\n",
    "        ls_ret = long_ret - short_ret\n",
    "        \n",
    "        long_returns.append(long_ret)\n",
    "        short_returns.append(short_ret)\n",
    "        ls_returns.append(ls_ret)\n",
    "        long_holdings.append(long_tickers)\n",
    "        short_holdings.append(short_tickers)\n",
    "        rebalance_dates.append(next_date)\n",
    "    \n",
    "    df_port = pd.DataFrame({\n",
    "        'long': long_returns,\n",
    "        'short': short_returns,\n",
    "        'ls': ls_returns,\n",
    "        'long_holdings': long_holdings,\n",
    "        'short_holdings': short_holdings\n",
    "    }, index=pd.DatetimeIndex(rebalance_dates))\n",
    "    \n",
    "    return df_port\n",
    "\n",
    "df_port = form_decile_portfolios(df_momentum, df_returns)\n",
    "print(f\"Backtest period: {df_port.index.min()} to {df_port.index.max()}\")\n",
    "print(f\"Number of rebalances: {len(df_port)}\")\n",
    "df_port[['long', 'short', 'ls']].describe().round(4)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a438dcfb",
   "metadata": {},
   "source": [
    "## Turnover and Transaction Costs\n",
    "\n",
    "One-way turnover measures how much of the portfolio has to be traded at each rebalance:\n",
    "\n",
    "$$\\text{turnover}_t=\\frac{1}{2}\\|w_t-w_{t-1}\\|_1.$$\n",
    "\n",
    "For a long-only portfolio, zero means nothing changed. A value near 1 means the portfolio was almost completely replaced. For the long-short book, we compute one-way turnover on the long side and short side and add them.\n",
    "\n",
    "Transaction costs are proportional to turnover:\n",
    "\n",
    "$$\\text{cost}_t=c\\times\\text{turnover}_t,$$\n",
    "\n",
    "where $c=5$ bps, or $0.0005$, for liquid US large-cap names. Net return is gross return minus cost. The first rebalance includes the initial cost of buying the portfolio."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "7f3b7fe9",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T12:20:21.440964Z",
     "iopub.status.busy": "2026-07-31T12:20:21.440773Z",
     "iopub.status.idle": "2026-07-31T12:20:22.027373Z",
     "shell.execute_reply": "2026-07-31T12:20:22.026659Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 103668 characters]",
      "text/plain": [
       "<Figure size 1200x500 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Average long turnover (one-way): 0.289\n",
      "Average L/S turnover (one-way):   0.596\n"
     ]
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Compute one-way turnover at each rebalance\n",
    "==================================\n",
    "\"\"\"\n",
    "def _equal_weight_vector(holdings):\n",
    "    \"\"\"Equal-weight vector for a list of holdings.\"\"\"\n",
    "    if len(holdings) == 0:\n",
    "        return pd.Series(dtype=float)\n",
    "    return pd.Series(1.0 / len(holdings), index=pd.Index(holdings), dtype=float)\n",
    "\n",
    "def compute_turnover(holdings_series):\n",
    "    \"\"\"Compute one-way turnover from actual target weight vectors.\"\"\"\n",
    "    turnovers = []\n",
    "    prev_w = None\n",
    "    for holdings in holdings_series:\n",
    "        curr_w = _equal_weight_vector(holdings)\n",
    "        if curr_w.empty:\n",
    "            turnover = 0.0\n",
    "        elif prev_w is None or prev_w.empty:\n",
    "            turnover = curr_w.abs().sum()  # initial buy into the portfolio\n",
    "        else:\n",
    "            names = prev_w.index.union(curr_w.index)\n",
    "            turnover = (curr_w.reindex(names, fill_value=0.0) - prev_w.reindex(names, fill_value=0.0)).abs().sum() / 2.0\n",
    "        turnovers.append(float(turnover))\n",
    "        prev_w = curr_w\n",
    "    return pd.Series(turnovers, index=holdings_series.index)\n",
    "\n",
    "df_port['long_turnover'] = compute_turnover(df_port['long_holdings'])\n",
    "df_port['short_turnover'] = compute_turnover(df_port['short_holdings'])\n",
    "df_port['ls_turnover'] = df_port['long_turnover'] + df_port['short_turnover']\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(12, 5))\n",
    "ax.plot(df_port.index, df_port['long_turnover'], color='steelblue', alpha=0.5, label='Long turnover')\n",
    "ax.plot(df_port.index, df_port['long_turnover'].rolling(12).mean(), color='coral', linewidth=2, label='12m MA')\n",
    "ax.set_ylabel('One-Way Turnover')\n",
    "ax.set_title('Long Portfolio Turnover')\n",
    "ax.legend()\n",
    "ax.grid(alpha=0.3)\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig('../images/04_backtest/turnover.png', dpi=150, bbox_inches='tight')\n",
    "plt.show()\n",
    "\n",
    "print(f\"Average long turnover (one-way): {df_port['long_turnover'].mean():.3f}\")\n",
    "print(f\"Average L/S turnover (one-way):   {df_port['ls_turnover'].mean():.3f}\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "d3cfa6a1",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T12:20:22.029262Z",
     "iopub.status.busy": "2026-07-31T12:20:22.029060Z",
     "iopub.status.idle": "2026-07-31T12:20:22.047668Z",
     "shell.execute_reply": "2026-07-31T12:20:22.047015Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Net return summary:\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>long_net</th>\n",
       "      <th>short_net</th>\n",
       "      <th>ls_net</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>count</th>\n",
       "      <td>239.0000</td>\n",
       "      <td>239.0000</td>\n",
       "      <td>239.0000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>mean</th>\n",
       "      <td>0.0162</td>\n",
       "      <td>0.0169</td>\n",
       "      <td>-0.0007</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>std</th>\n",
       "      <td>0.0560</td>\n",
       "      <td>0.0700</td>\n",
       "      <td>0.0479</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>min</th>\n",
       "      <td>-0.1797</td>\n",
       "      <td>-0.2208</td>\n",
       "      <td>-0.4088</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>25%</th>\n",
       "      <td>-0.0117</td>\n",
       "      <td>-0.0209</td>\n",
       "      <td>-0.0217</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>50%</th>\n",
       "      <td>0.0174</td>\n",
       "      <td>0.0144</td>\n",
       "      <td>0.0019</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>75%</th>\n",
       "      <td>0.0496</td>\n",
       "      <td>0.0488</td>\n",
       "      <td>0.0247</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>max</th>\n",
       "      <td>0.1725</td>\n",
       "      <td>0.4797</td>\n",
       "      <td>0.1005</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "       long_net  short_net    ls_net\n",
       "count  239.0000   239.0000  239.0000\n",
       "mean     0.0162     0.0169   -0.0007\n",
       "std      0.0560     0.0700    0.0479\n",
       "min     -0.1797    -0.2208   -0.4088\n",
       "25%     -0.0117    -0.0209   -0.0217\n",
       "50%      0.0174     0.0144    0.0019\n",
       "75%      0.0496     0.0488    0.0247\n",
       "max      0.1725     0.4797    0.1005"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Apply transaction costs\n",
    "==================================\n",
    "\n",
    "Cost = one-way turnover * cost in bps / 10000.\n",
    "For the long-short book, long and short costs are applied separately.\n",
    "\"\"\"\n",
    "tc = TRANSACTION_COST_BPS / 10000  # 5 bps = 0.0005\n",
    "\n",
    "# Net returns = gross - cost\n",
    "df_port['long_net'] = df_port['long'] - df_port['long_turnover'] * tc\n",
    "df_port['short_net'] = df_port['short'] + df_port['short_turnover'] * tc  # Short pays cost too\n",
    "df_port['ls_net'] = df_port['long_net'] - df_port['short_net']\n",
    "\n",
    "# Excess returns (subtract risk-free)\n",
    "# Align FF data (month-start) to portfolio index (month-end) via period index\n",
    "df_port_pm = df_port.copy()\n",
    "df_port_pm.index = df_port_pm.index.to_period('M')\n",
    "ff_pm = df_ff[['RF', 'Mkt-RF']].copy()\n",
    "ff_pm.index = ff_pm.index.to_period('M')\n",
    "df_port['RF'] = ff_pm['RF'].reindex(df_port_pm.index).values\n",
    "df_port['Mkt-RF'] = ff_pm['Mkt-RF'].reindex(df_port_pm.index).values\n",
    "df_port['long_excess'] = df_port['long_net'] - df_port['RF']\n",
    "df_port['short_excess'] = df_port['short_net'] - df_port['RF']\n",
    "df_port['ls_excess'] = df_port['ls_net']  # L/S is dollar-neutral, RF cancels\n",
    "\n",
    "print(\"Net return summary:\")\n",
    "display(df_port[['long_net', 'short_net', 'ls_net']].describe().round(4))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cb3baab4",
   "metadata": {},
   "source": [
    "## Performance Metrics\n",
    "\n",
    "The main metrics are:\n",
    "\n",
    "- **Sharpe ratio:**\n",
    "\n",
    "$$\\text{Sharpe}=\\frac{\\bar r_p}{\\mathrm{std}(r_p)}\\sqrt{12}.$$\n",
    "\n",
    "- **Sortino ratio:** like Sharpe, but the denominator is downside deviation rather than total volatility.\n",
    "- **Max drawdown:** the worst percentage drop from a previous wealth peak:\n",
    "\n",
    "$$\\text{DD}_t=\\frac{V_t-\\max_{s\\le t}V_s}{\\max_{s\\le t}V_s}, \\quad V_t=\\prod_{s=1}^{t}(1+r_{p,s}).$$\n",
    "\n",
    "- **Calmar ratio:** annualized return divided by the absolute value of max drawdown.\n",
    "\n",
    "These are descriptive statistics. They do not prove a strategy is good, but they help us understand the shape of the returns."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6ab72d21",
   "metadata": {},
   "source": [
    "## Sortino Ratio and Max Drawdown\n",
    "\n",
    "### Sortino Ratio\n",
    "\n",
    "Sharpe uses total volatility in the denominator. That means upside and downside moves both increase the denominator. Sortino replaces total volatility with downside deviation, so months above the target return do not count as risk.\n",
    "\n",
    "With target return $r_{\\text{target}}=0$:\n",
    "\n",
    "$$\\sigma_D=\\sqrt{\\frac{1}{T}\\sum_{t=1}^{T}\\min(0,r_t-r_{\\text{target}})^2},$$\n",
    "\n",
    "and\n",
    "\n",
    "$$\\text{Sortino}=\\frac{\\bar r_p}{\\sigma_D}\\sqrt{12}.$$\n",
    "\n",
    "Example monthly returns:\n",
    "\n",
    "$$[0.03,-0.02,0.08,-0.01,0.04].$$\n",
    "\n",
    "Only the negative months enter downside deviation:\n",
    "\n",
    "| Month | Return | Downside part | Squared |\n",
    "|---|---:|---:|---:|\n",
    "| 1 | 0.03 | 0.00 | 0.0000 |\n",
    "| 2 | -0.02 | -0.02 | 0.0004 |\n",
    "| 3 | 0.08 | 0.00 | 0.0000 |\n",
    "| 4 | -0.01 | -0.01 | 0.0001 |\n",
    "| 5 | 0.04 | 0.00 | 0.0000 |\n",
    "\n",
    "So\n",
    "\n",
    "$$\\sigma_D=\\sqrt{(0.0004+0.0001)/5}=0.01.$$\n",
    "\n",
    "Sortino is useful when the return distribution is asymmetric. A large gap between Sortino and Sharpe usually means the strategy has more upside volatility than downside volatility.\n",
    "\n",
    "### Max Drawdown\n",
    "\n",
    "Cumulative wealth is\n",
    "\n",
    "$$V_t=(1+r_1)(1+r_2)\\cdots(1+r_t).$$\n",
    "\n",
    "The running peak is\n",
    "\n",
    "$$P_t=\\max_{s\\le t}V_s.$$\n",
    "\n",
    "Drawdown is the percentage distance below that peak:\n",
    "\n",
    "$$\\text{DD}_t=\\frac{V_t-P_t}{P_t}.$$\n",
    "\n",
    "Example:\n",
    "\n",
    "| Month | Wealth $V_t$ | Running peak $P_t$ | Drawdown |\n",
    "|---|---:|---:|---:|\n",
    "| 1 | 1.05 | 1.05 | 0.0% |\n",
    "| 2 | 1.08 | 1.08 | 0.0% |\n",
    "| 3 | 1.02 | 1.08 | -5.6% |\n",
    "| 4 | 0.95 | 1.08 | -12.0% |\n",
    "| 5 | 1.01 | 1.08 | -6.5% |\n",
    "\n",
    "Max drawdown is the worst value in that drawdown series. It answers: how far underwater would an investor have been at the worst point?\n",
    "\n",
    "Calmar then asks how much annual return the strategy earned per unit of that worst drawdown:\n",
    "\n",
    "$$\\text{Calmar}=\\frac{\\text{annualized return}}{|\\text{max drawdown}|}.$$"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d2886153",
   "metadata": {},
   "source": [
    "### Equal-Weight (EW) Universe Benchmark\n",
    "\n",
    "The equal-weight universe holds every available stock at the same weight:\n",
    "\n",
    "$$w_i=\\frac{1}{N_t}.$$\n",
    "\n",
    "Its return is the row mean of the return matrix:\n",
    "\n",
    "$$\\bar r_t=\\frac{1}{N_t}\\mathbf{1}^\\top r_t.$$\n",
    "\n",
    "This is a better benchmark for this project than a cap-weighted index because our portfolio is also equal-weighted within its selected names. Comparing equal-weight to equal-weight keeps the focus on selection: did the top-momentum decile beat simply holding the whole available universe equally?\n",
    "\n",
    "The difference $r_{p,t}-\\bar r_t$ is the **active return**, and its annualized mean divided by tracking error is the **information ratio**."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "4845c9f1",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T12:20:22.049879Z",
     "iopub.status.busy": "2026-07-31T12:20:22.049541Z",
     "iopub.status.idle": "2026-07-31T12:20:22.070626Z",
     "shell.execute_reply": "2026-07-31T12:20:22.069944Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Performance Summary\n",
      "\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>ann_return</th>\n",
       "      <th>ann_vol</th>\n",
       "      <th>sharpe</th>\n",
       "      <th>sortino</th>\n",
       "      <th>max_drawdown</th>\n",
       "      <th>calmar</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>long_net</th>\n",
       "      <td>0.1948</td>\n",
       "      <td>0.1941</td>\n",
       "      <td>1.0036</td>\n",
       "      <td>1.6436</td>\n",
       "      <td>-0.5713</td>\n",
       "      <td>0.3409</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>ls_net</th>\n",
       "      <td>-0.0081</td>\n",
       "      <td>0.1658</td>\n",
       "      <td>-0.0491</td>\n",
       "      <td>-0.0590</td>\n",
       "      <td>-0.7029</td>\n",
       "      <td>-0.0116</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>ew_universe</th>\n",
       "      <td>0.1594</td>\n",
       "      <td>0.1683</td>\n",
       "      <td>0.9471</td>\n",
       "      <td>1.5203</td>\n",
       "      <td>-0.4747</td>\n",
       "      <td>0.3359</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "             ann_return  ann_vol  sharpe  sortino  max_drawdown  calmar\n",
       "long_net         0.1948   0.1941  1.0036   1.6436       -0.5713  0.3409\n",
       "ls_net          -0.0081   0.1658 -0.0491  -0.0590       -0.7029 -0.0116\n",
       "ew_universe      0.1594   0.1683  0.9471   1.5203       -0.4747  0.3359"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "\n",
      "Active Return (Long-Only minus EW Universe)\n",
      "  Monthly active return: 0.00295 (t = 1.89)\n",
      "  Annualized:            0.0354\n",
      "  Tracking error:        0.0833\n",
      "  Information ratio:     0.4243\n"
     ]
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Compute performance metrics\n",
    "==================================\n",
    "\"\"\"\n",
    "def performance_metrics(returns, freq=12, rf=0):\n",
    "    \"\"\"Compute standard performance metrics.\"\"\"\n",
    "    returns = returns.dropna()\n",
    "    excess = returns - rf\n",
    "    ann_return = returns.mean() * freq\n",
    "    ann_vol = returns.std() * np.sqrt(freq)\n",
    "    sharpe = ann_return / ann_vol if ann_vol > 0 else np.nan\n",
    "\n",
    "    downside = np.minimum(excess, 0.0)\n",
    "    downside_vol = np.sqrt(np.mean(downside ** 2)) * np.sqrt(freq) if len(downside) > 0 else np.nan\n",
    "    sortino = ann_return / downside_vol if downside_vol and downside_vol > 0 else np.nan\n",
    "\n",
    "    cum = (1 + returns).cumprod()\n",
    "    running_max = cum.cummax()\n",
    "    drawdown = (cum - running_max) / running_max\n",
    "    max_dd = drawdown.min()\n",
    "    \n",
    "    calmar = ann_return / abs(max_dd) if max_dd < 0 else np.nan\n",
    "    \n",
    "    return {\n",
    "        'ann_return': ann_return,\n",
    "        'ann_vol': ann_vol,\n",
    "        'sharpe': sharpe,\n",
    "        'sortino': sortino,\n",
    "        'max_drawdown': max_dd,\n",
    "        'calmar': calmar\n",
    "    }\n",
    "\n",
    "# Equal-weight universe benchmark — align via period index\n",
    "ew_monthly = df_returns.mean(axis=1)\n",
    "ew_monthly.index = ew_monthly.index.to_period('M')\n",
    "df_port['ew_universe'] = ew_monthly.reindex(df_port.index.to_period('M')).values\n",
    "\n",
    "# Compute metrics for all portfolios\n",
    "metrics = {}\n",
    "for col, label in [('long_net', 'Long-Only (net)'), ('ls_net', 'Long-Short (net)'), ('ew_universe', 'EW Universe')]:\n",
    "    metrics[col] = performance_metrics(df_port[col])\n",
    "\n",
    "df_metrics = pd.DataFrame(metrics).T.round(4)\n",
    "print(\"Performance Summary\\n\")\n",
    "display(df_metrics)\n",
    "\n",
    "# Active return vs equal-weight universe\n",
    "df_port['active'] = df_port['long_net'] - df_port['ew_universe']\n",
    "active = df_port['active'].dropna()\n",
    "t_stat = active.mean() / (active.std() / np.sqrt(len(active)))\n",
    "ir = active.mean() / active.std() * np.sqrt(12)\n",
    "print(f\"\\nActive Return (Long-Only minus EW Universe)\")\n",
    "print(f\"  Monthly active return: {active.mean():.5f} (t = {t_stat:.2f})\")\n",
    "print(f\"  Annualized:            {active.mean()*12:.4f}\")\n",
    "print(f\"  Tracking error:        {active.std()*np.sqrt(12):.4f}\")\n",
    "print(f\"  Information ratio:     {ir:.4f}\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "e9999e92",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T12:20:22.073634Z",
     "iopub.status.busy": "2026-07-31T12:20:22.073011Z",
     "iopub.status.idle": "2026-07-31T12:20:22.927406Z",
     "shell.execute_reply": "2026-07-31T12:20:22.926772Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 154172 characters]",
      "text/plain": [
       "<Figure size 1400x1000 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Equity curve and drawdown\n",
    "==================================\n",
    "\"\"\"\n",
    "fig, axes = plt.subplots(2, 1, figsize=(14, 10), sharex=True)\n",
    "\n",
    "# Cumulative returns\n",
    "ax = axes[0]\n",
    "for col, color, label in [('long_net', 'steelblue', 'Long-Only'), ('ls_net', 'coral', 'Long-Short')]:\n",
    "    cum = (1 + df_port[col]).cumprod()\n",
    "    ax.plot(cum.index, cum.values, color=color, linewidth=2, label=label)\n",
    "ax.set_ylabel('Cumulative Return')\n",
    "ax.set_title('Portfolio Equity Curves (Net of Costs)')\n",
    "ax.legend()\n",
    "ax.grid(alpha=0.3)\n",
    "\n",
    "# Drawdown\n",
    "ax = axes[1]\n",
    "for col, color, label in [('long_net', 'steelblue', 'Long-Only'), ('ls_net', 'coral', 'Long-Short')]:\n",
    "    cum = (1 + df_port[col]).cumprod()\n",
    "    dd = (cum - cum.cummax()) / cum.cummax()\n",
    "    ax.fill_between(dd.index, dd.values, 0, color=color, alpha=0.3, label=label)\n",
    "ax.set_ylabel('Drawdown')\n",
    "ax.set_title('Portfolio Drawdowns')\n",
    "ax.legend()\n",
    "ax.grid(alpha=0.3)\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig('../images/04_backtest/equity_and_drawdown.png', dpi=150, bbox_inches='tight')\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "54006d1a",
   "metadata": {},
   "source": [
    "## Walk-Forward Analysis\n",
    "\n",
    "A full-sample Sharpe can be misleading if one period did all the work. Walk-forward analysis splits the history into 5-year windows and recomputes performance in each window.\n",
    "\n",
    "This is a consistency check, not a parameter search. The question is whether the strategy works across more than one regime."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "id": "a0a6cf4f",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T12:20:22.929561Z",
     "iopub.status.busy": "2026-07-31T12:20:22.929356Z",
     "iopub.status.idle": "2026-07-31T12:20:23.333476Z",
     "shell.execute_reply": "2026-07-31T12:20:23.332807Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Walk-Forward Performance (5-Year Windows)\n",
      "\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>lo_sharpe</th>\n",
       "      <th>lo_ann_ret</th>\n",
       "      <th>ls_sharpe</th>\n",
       "      <th>ew_sharpe</th>\n",
       "      <th>active_ret</th>\n",
       "      <th>info_ratio</th>\n",
       "      <th>active_t</th>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>period</th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>2006-2011</th>\n",
       "      <td>0.3108</td>\n",
       "      <td>0.0703</td>\n",
       "      <td>-0.5493</td>\n",
       "      <td>0.5729</td>\n",
       "      <td>-0.0495</td>\n",
       "      <td>-0.5230</td>\n",
       "      <td>-1.1596</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2011-2016</th>\n",
       "      <td>1.4347</td>\n",
       "      <td>0.2105</td>\n",
       "      <td>0.6602</td>\n",
       "      <td>1.3470</td>\n",
       "      <td>0.0420</td>\n",
       "      <td>0.7802</td>\n",
       "      <td>1.7445</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2016-2021</th>\n",
       "      <td>1.2810</td>\n",
       "      <td>0.2515</td>\n",
       "      <td>-0.0018</td>\n",
       "      <td>1.1172</td>\n",
       "      <td>0.0592</td>\n",
       "      <td>0.7994</td>\n",
       "      <td>1.7874</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2021-2026</th>\n",
       "      <td>1.2279</td>\n",
       "      <td>0.2447</td>\n",
       "      <td>0.2942</td>\n",
       "      <td>0.9738</td>\n",
       "      <td>0.0883</td>\n",
       "      <td>0.8852</td>\n",
       "      <td>1.9795</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "           lo_sharpe  lo_ann_ret  ls_sharpe  ew_sharpe  active_ret  \\\n",
       "period                                                               \n",
       "2006-2011     0.3108      0.0703    -0.5493     0.5729     -0.0495   \n",
       "2011-2016     1.4347      0.2105     0.6602     1.3470      0.0420   \n",
       "2016-2021     1.2810      0.2515    -0.0018     1.1172      0.0592   \n",
       "2021-2026     1.2279      0.2447     0.2942     0.9738      0.0883   \n",
       "\n",
       "           info_ratio  active_t  \n",
       "period                           \n",
       "2006-2011     -0.5230   -1.1596  \n",
       "2011-2016      0.7802    1.7445  \n",
       "2016-2021      0.7994    1.7874  \n",
       "2021-2026      0.8852    1.9795  "
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "image/png": "[encoded data, 37076 characters]",
      "text/plain": [
       "<Figure size 1000x600 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "\n",
      "Long-Only Sharpe positive in 4/4 windows\n",
      "Active return positive in 3/4 windows\n"
     ]
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Walk-forward analysis: 5-year windows\n",
    "==================================\n",
    "\"\"\"\n",
    "windows = [(2006, 2011), (2011, 2016), (2016, 2021), (2021, 2026)]\n",
    "\n",
    "wf_results = []\n",
    "for start, end in windows:\n",
    "    mask = (df_port.index.year >= start) & (df_port.index.year < end)\n",
    "    if mask.sum() < 12:\n",
    "        continue\n",
    "    \n",
    "    m_lo = performance_metrics(df_port.loc[mask, 'long_net'])\n",
    "    m_ls = performance_metrics(df_port.loc[mask, 'ls_net'])\n",
    "    m_ew = performance_metrics(df_port.loc[mask, 'ew_universe'])\n",
    "    \n",
    "    active_sub = df_port.loc[mask, 'active'].dropna()\n",
    "    ir = active_sub.mean() / active_sub.std() * np.sqrt(12) if len(active_sub) > 12 and active_sub.std() > 0 else np.nan\n",
    "    t_act = active_sub.mean() / (active_sub.std() / np.sqrt(len(active_sub))) if len(active_sub) > 12 else np.nan\n",
    "    \n",
    "    wf_results.append({\n",
    "        'period': f'{start}-{end}',\n",
    "        'lo_sharpe': m_lo['sharpe'],\n",
    "        'lo_ann_ret': m_lo['ann_return'],\n",
    "        'ls_sharpe': m_ls['sharpe'],\n",
    "        'ew_sharpe': m_ew['sharpe'],\n",
    "        'active_ret': active_sub.mean() * 12,\n",
    "        'info_ratio': ir,\n",
    "        'active_t': t_act\n",
    "    })\n",
    "\n",
    "df_wf = pd.DataFrame(wf_results).set_index('period')\n",
    "print(\"Walk-Forward Performance (5-Year Windows)\\n\")\n",
    "display(df_wf.round(4))\n",
    "\n",
    "# Plot walk-forward Sharpe ratios\n",
    "fig, ax = plt.subplots(figsize=(10, 6))\n",
    "x = np.arange(len(df_wf))\n",
    "width = 0.25\n",
    "ax.bar(x - width, df_wf['lo_sharpe'], width, label='Long-Only', color='steelblue')\n",
    "ax.bar(x, df_wf['ew_sharpe'], width, label='EW Universe', color='coral')\n",
    "ax.bar(x + width, df_wf['ls_sharpe'], width, label='Long-Short', color='seagreen')\n",
    "ax.set_xticks(x)\n",
    "ax.set_xticklabels(df_wf.index)\n",
    "ax.set_ylabel('Sharpe Ratio')\n",
    "ax.set_title('Walk-Forward Sharpe by Subperiod')\n",
    "ax.axhline(y=0, color='black', linewidth=0.5)\n",
    "ax.legend()\n",
    "ax.grid(alpha=0.3, axis='y')\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig('../images/04_backtest/walk_forward.png', dpi=150, bbox_inches='tight')\n",
    "plt.show()\n",
    "\n",
    "print(f\"\\nLong-Only Sharpe positive in {(df_wf['lo_sharpe'] > 0).sum()}/{len(df_wf)} windows\")\n",
    "print(f\"Active return positive in {(df_wf['active_ret'] > 0).sum()}/{len(df_wf)} windows\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "14f058aa",
   "metadata": {},
   "source": [
    "## Reconciling Walk-Forward IC vs. Walk-Forward Sharpe\n",
    "\n",
    "Notebook 02 found that momentum's monthly IC was positive in only some subperiods, while the long-only portfolio Sharpe can still be positive across all windows. Those are not contradictory.\n",
    "\n",
    "IC measures cross-sectional ordering: did higher-ranked stocks beat lower-ranked stocks? Portfolio Sharpe measures the return level of the selected stocks. A top-decile book can make money in a window even if its ranking skill is weak, especially if the selected stocks had high market beta during a rising market.\n",
    "\n",
    "The table below compares mean IC, portfolio Sharpe, and realized market beta by window. If high beta lines up with weak IC, the Sharpe is probably being carried by market exposure rather than stock selection."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "id": "508e0b07",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T12:20:23.335403Z",
     "iopub.status.busy": "2026-07-31T12:20:23.335208Z",
     "iopub.status.idle": "2026-07-31T12:20:23.360707Z",
     "shell.execute_reply": "2026-07-31T12:20:23.360069Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "IC vs. Sharpe vs. realized market beta, by walk-forward window:\n",
      "\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>mean_ic</th>\n",
       "      <th>portfolio_sharpe</th>\n",
       "      <th>portfolio_mkt_beta</th>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>period</th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>2006-2011</th>\n",
       "      <td>-0.013</td>\n",
       "      <td>0.311</td>\n",
       "      <td>1.144</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2011-2016</th>\n",
       "      <td>0.028</td>\n",
       "      <td>1.435</td>\n",
       "      <td>1.105</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2016-2021</th>\n",
       "      <td>-0.008</td>\n",
       "      <td>1.281</td>\n",
       "      <td>1.127</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2021-2026</th>\n",
       "      <td>0.017</td>\n",
       "      <td>1.228</td>\n",
       "      <td>1.171</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "           mean_ic  portfolio_sharpe  portfolio_mkt_beta\n",
       "period                                                  \n",
       "2006-2011   -0.013             0.311               1.144\n",
       "2011-2016    0.028             1.435               1.105\n",
       "2016-2021   -0.008             1.281               1.127\n",
       "2021-2026    0.017             1.228               1.171"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "\n",
      "If portfolio_mkt_beta is visibly higher in the windows where mean_ic is negative, that is the mechanical explanation: the decile's Sharpe in that window is being carried by market exposure rather than by the ranking signal actually working that period.\n"
     ]
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Reconcile factor-level IC (notebook 02) with portfolio-level Sharpe, by window\n",
    "==================================\n",
    "\"\"\"\n",
    "df_ic_momentum = pd.read_csv('../data/processed/ic_monthly.csv', index_col=0, parse_dates=True)['momentum']\n",
    "\n",
    "reconcile_rows = []\n",
    "for start, end in windows:\n",
    "    mask_port = (df_port.index.year >= start) & (df_port.index.year < end)\n",
    "    mask_ic = (df_ic_momentum.index.year >= start) & (df_ic_momentum.index.year < end)\n",
    "\n",
    "    sub_port = df_port.loc[mask_port].dropna(subset=['long_excess', 'Mkt-RF'])\n",
    "    sub_ic = df_ic_momentum.loc[mask_ic].dropna()\n",
    "\n",
    "    if len(sub_port) > 3 and sub_port['Mkt-RF'].var() > 0:\n",
    "        port_beta = sub_port['long_excess'].cov(sub_port['Mkt-RF']) / sub_port['Mkt-RF'].var()\n",
    "    else:\n",
    "        port_beta = np.nan\n",
    "\n",
    "    reconcile_rows.append({\n",
    "        'period': f'{start}-{end}',\n",
    "        'mean_ic': sub_ic.mean(),\n",
    "        'portfolio_sharpe': performance_metrics(df_port.loc[mask_port, 'long_net'])['sharpe'],\n",
    "        'portfolio_mkt_beta': port_beta,\n",
    "    })\n",
    "\n",
    "df_reconcile = pd.DataFrame(reconcile_rows).set_index('period')\n",
    "print(\"IC vs. Sharpe vs. realized market beta, by walk-forward window:\\n\")\n",
    "display(df_reconcile.round(3))\n",
    "\n",
    "print(\n",
    "    \"\\nIf portfolio_mkt_beta is visibly higher in the windows where mean_ic is negative, \"\n",
    "    \"that is the mechanical explanation: the decile's Sharpe in that window is being carried \"\n",
    "    \"by market exposure rather than by the ranking signal actually working that period.\"\n",
    ")\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "79a4d8c5",
   "metadata": {},
   "source": [
    "## Fama-French Alpha\n",
    "\n",
    "The Fama-French regression is an OLS regression of portfolio excess returns on benchmark factor returns. Given factor matrix $F \\in \\mathbb{R}^{T\\times k}$ and portfolio excess returns $y\\in\\mathbb{R}^T$, the fitted model is\n",
    "\n",
    "$$y=\\alpha\\mathbf{1}+F\\beta+\\varepsilon.$$\n",
    "\n",
    "The least-squares beta estimate is\n",
    "\n",
    "$$\\hat\\beta=(F^\\top F)^{-1}F^\\top(y-\\alpha\\mathbf{1}),$$\n",
    "\n",
    "or equivalently the full coefficient vector is estimated after adding a column of ones to $F$.\n",
    "\n",
    "- **Betas:** exposures to benchmark factors such as market, size, value, and momentum.\n",
    "- **Alpha:** the intercept. It is the average return left after controlling for those factor exposures.\n",
    "- **Residuals:** the month-by-month unexplained returns around the fitted line.\n",
    "\n",
    "So alpha is related to the residual, but it is not the entire residual vector. It is the average unexplained return, annualized here by multiplying the monthly intercept by 12.\n",
    "\n",
    "We report the usual OLS t-statistic and a **HAC/Newey-West** t-statistic with three monthly lags. HAC standard errors are a useful check because monthly portfolio residuals can have mild autocorrelation or changing volatility. If the alpha only survives under plain OLS and disappears under HAC, the result is less convincing.\n",
    "\n",
    "We run this on the long-only portfolio as the headline result and on the long-short portfolio as a comparison."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 11,
   "id": "10c3bb8c",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T12:20:23.362870Z",
     "iopub.status.busy": "2026-07-31T12:20:23.362667Z",
     "iopub.status.idle": "2026-07-31T12:20:23.581378Z",
     "shell.execute_reply": "2026-07-31T12:20:23.580746Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Regression data points: 239\n",
      "============================================================\n",
      "LONG-ONLY: Fama-French 4-Factor Alpha\n",
      "============================================================\n",
      "  Alpha (monthly):       0.00497\n",
      "  Alpha (annualized):    0.0597\n",
      "  Alpha t-stat (OLS):    3.98\n",
      "  Alpha t-stat (HAC):    4.17  (Newey-West, 3 lags)\n",
      "  MKT beta:              1.189 (t=39.27)\n",
      "  SMB beta:              0.262 (t=5.02)\n",
      "  HML beta:              -0.045 (t=-1.12)\n",
      "  MOM beta:              0.251 (t=7.95)\n",
      "  R^2:                   0.889\n",
      "\n",
      "============================================================\n",
      "LONG-SHORT: Fama-French 4-Factor Alpha (comparison)\n",
      "============================================================\n",
      "  Alpha (monthly):       -0.00246\n",
      "  Alpha (annualized):    -0.0295\n",
      "  Alpha t-stat (OLS):    -1.41\n",
      "  Alpha t-stat (HAC):    -1.37  (Newey-West, 3 lags)\n",
      "  MKT beta:              0.144 (t=3.42)\n",
      "  MOM beta:              0.905 (t=20.62)\n",
      "  R^2:                   0.704\n",
      "\n",
      "============================================================\n",
      "SUMMARY\n",
      "  Long-only alpha:  +0.0597 (OLS t=3.98, HAC t=4.17)\n",
      "  Long-short alpha: -0.0295 (OLS t=-1.41, HAC t=-1.37)\n",
      "============================================================\n"
     ]
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Fama-French 4-factor regression: Long-Only and Long-Short\n",
    "==================================\n",
    "\"\"\"\n",
    "import statsmodels.api as sm\n",
    "\n",
    "FF_FACTOR_COLS = ['Mkt-RF', 'SMB', 'HML', 'Mom']\n",
    "HAC_LAGS = 3\n",
    "\n",
    "# --- Robust alignment via year-month period index ---\n",
    "# df_port has month-end dates, df_ff has month-start dates.\n",
    "# Align both to PeriodIndex('M') so they match regardless of timestamp conventions.\n",
    "df_port_pm = df_port.copy()\n",
    "df_port_pm.index = df_port_pm.index.to_period('M')\n",
    "\n",
    "df_ff_pm = df_ff[FF_FACTOR_COLS].copy()\n",
    "df_ff_pm.index = df_ff_pm.index.to_period('M')\n",
    "\n",
    "df_reg = df_port_pm.join(df_ff_pm, how='inner', lsuffix='_port', rsuffix='_ff')\n",
    "\n",
    "# Resolve any column-name collisions (Mkt-RF may exist in both from cell 10)\n",
    "for col in FF_FACTOR_COLS:\n",
    "    if col + '_ff' in df_reg.columns:\n",
    "        df_reg[col] = df_reg[col + '_ff']\n",
    "\n",
    "df_reg = df_reg[['long_excess', 'ls_net'] + FF_FACTOR_COLS].dropna().astype(float)\n",
    "\n",
    "print(f\"Regression data points: {len(df_reg)}\")\n",
    "\n",
    "\n",
    "def fit_ff_model(y):\n",
    "    \"\"\"Fit FF regression with ordinary and HAC/Newey-West covariance.\"\"\"\n",
    "    X = sm.add_constant(df_reg[FF_FACTOR_COLS], has_constant='add')\n",
    "    ols = sm.OLS(y, X).fit()\n",
    "    hac = sm.OLS(y, X).fit(cov_type='HAC', cov_kwds={'maxlags': HAC_LAGS})\n",
    "    return ols, hac\n",
    "\n",
    "\n",
    "def print_ff_model(label, model, model_hac, show_all_betas=True):\n",
    "    print(\"=\" * 60)\n",
    "    print(label)\n",
    "    print(\"=\" * 60)\n",
    "    print(f\"  Alpha (monthly):       {model.params['const']:.5f}\")\n",
    "    print(f\"  Alpha (annualized):    {model.params['const']*12:.4f}\")\n",
    "    print(f\"  Alpha t-stat (OLS):    {model.tvalues['const']:.2f}\")\n",
    "    print(f\"  Alpha t-stat (HAC):    {model_hac.tvalues['const']:.2f}  (Newey-West, {HAC_LAGS} lags)\")\n",
    "    print(f\"  MKT beta:              {model.params['Mkt-RF']:.3f} (t={model.tvalues['Mkt-RF']:.2f})\")\n",
    "    if show_all_betas:\n",
    "        print(f\"  SMB beta:              {model.params['SMB']:.3f} (t={model.tvalues['SMB']:.2f})\")\n",
    "        print(f\"  HML beta:              {model.params['HML']:.3f} (t={model.tvalues['HML']:.2f})\")\n",
    "        print(f\"  MOM beta:              {model.params['Mom']:.3f} (t={model.tvalues['Mom']:.2f})\")\n",
    "    else:\n",
    "        print(f\"  MOM beta:              {model.params['Mom']:.3f} (t={model.tvalues['Mom']:.2f})\")\n",
    "    print(f\"  R^2:                   {model.rsquared:.3f}\")\n",
    "\n",
    "if len(df_reg) == 0:\n",
    "    print(\"ERROR: No overlapping data between backtest and Fama-French factors.\")\n",
    "    print(\"Check the indices of df_port and df_ff.\")\n",
    "else:\n",
    "    model_lo, model_lo_hac = fit_ff_model(df_reg['long_excess'])\n",
    "    print_ff_model(\"LONG-ONLY: Fama-French 4-Factor Alpha\", model_lo, model_lo_hac)\n",
    "\n",
    "    model_ls, model_ls_hac = fit_ff_model(df_reg['ls_net'])\n",
    "    print()\n",
    "    print_ff_model(\"LONG-SHORT: Fama-French 4-Factor Alpha (comparison)\", model_ls, model_ls_hac, show_all_betas=False)\n",
    "\n",
    "    print(f\"\\n{'=' * 60}\")\n",
    "    print(\"SUMMARY\")\n",
    "    print(f\"  Long-only alpha:  {model_lo.params['const']*12:+.4f} \"\n",
    "          f\"(OLS t={model_lo.tvalues['const']:.2f}, HAC t={model_lo_hac.tvalues['const']:.2f})\")\n",
    "    print(f\"  Long-short alpha: {model_ls.params['const']*12:+.4f} \"\n",
    "          f\"(OLS t={model_ls.tvalues['const']:.2f}, HAC t={model_ls_hac.tvalues['const']:.2f})\")\n",
    "    print(\"=\" * 60)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0a7da761",
   "metadata": {},
   "source": [
    "### What Does MKT Beta = 1.19 Mean for the Headline Alpha?\n",
    "\n",
    "The regression reports a market beta along with alpha. A beta around 1.19 means the long-only decile had about 19% more market exposure than a beta-1 portfolio over this sample.\n",
    "\n",
    "That matters because part of the raw return may be ordinary market risk, not stock selection. This is why the factor-adjusted alpha is more informative than the raw active return. The regression subtracts the part explained by market, size, value, and momentum exposure before estimating the intercept.\n",
    "\n",
    "To make the idea concrete, we compare the portfolio to a beta-matched benchmark: the equal-weight universe scaled to the same market beta. This is not a tradable recommendation; it is a diagnostic for whether the outperformance survives a simple market-risk adjustment."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 12,
   "id": "519db558",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T12:20:23.583614Z",
     "iopub.status.busy": "2026-07-31T12:20:23.583280Z",
     "iopub.status.idle": "2026-07-31T12:20:23.590261Z",
     "shell.execute_reply": "2026-07-31T12:20:23.589581Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Long-only ann. return:                0.1948\n",
      "EW universe ann. return (unscaled):    0.1594\n",
      "EW universe ann. return (x1.19 beta-matched): 0.1895\n",
      "\n",
      "Outperformance vs. unscaled EW:        +0.0354\n",
      "Outperformance vs. beta-matched EW:     +0.0052\n",
      "\n",
      "If the beta-matched gap is still clearly positive, the outperformance is not simply leverage on the market factor; this is a second, more direct check on the same question the FF alpha answers via regression.\n"
     ]
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Beta-matched benchmark comparison\n",
    "==================================\n",
    "\"\"\"\n",
    "beta_lo = model_lo.params['Mkt-RF']  # MKT-RF coefficient from the FF regression above\n",
    "\n",
    "ew_scaled = df_port['ew_universe'] * beta_lo\n",
    "\n",
    "lo_ann_ret = df_port['long_net'].mean() * 12\n",
    "ew_ann_ret = df_port['ew_universe'].mean() * 12\n",
    "ew_scaled_ann_ret = ew_scaled.mean() * 12\n",
    "\n",
    "print(f\"Long-only ann. return:                {lo_ann_ret:.4f}\")\n",
    "print(f\"EW universe ann. return (unscaled):    {ew_ann_ret:.4f}\")\n",
    "print(f\"EW universe ann. return (x{beta_lo:.2f} beta-matched): {ew_scaled_ann_ret:.4f}\")\n",
    "print(f\"\\nOutperformance vs. unscaled EW:        {lo_ann_ret - ew_ann_ret:+.4f}\")\n",
    "print(f\"Outperformance vs. beta-matched EW:     {lo_ann_ret - ew_scaled_ann_ret:+.4f}\")\n",
    "print(\n",
    "    \"\\nIf the beta-matched gap is still clearly positive, the outperformance is not simply \"\n",
    "    \"leverage on the market factor; this is a second, more direct check on the same question \"\n",
    "    \"the FF alpha answers via regression.\"\n",
    ")\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b8020d29",
   "metadata": {},
   "source": [
    "## Survivorship Bias Sensitivity\n",
    "\n",
    "The universe is based on current S&P 500 constituents. That means stocks that disappeared from the index, were acquired, delisted, or went bankrupt may be missing from the historical panel.\n",
    "\n",
    "We cannot fully fix this without survivorship-free data. Instead, we ask a sensitivity question: how much annual return drag would we need to subtract before the Fama-French alpha is no longer statistically significant?\n",
    "\n",
    "This is not a perfect model of delisting bias. It is a breakeven calculation. If a small drag erases the result, the backtest is fragile. If a large drag is needed, the result is less likely to be explained only by survivorship bias."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 13,
   "id": "5a4bcd3f",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T12:20:23.592778Z",
     "iopub.status.busy": "2026-07-31T12:20:23.592552Z",
     "iopub.status.idle": "2026-07-31T12:20:23.971023Z",
     "shell.execute_reply": "2026-07-31T12:20:23.970290Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "### Survivorship Bias Sensitivity\n",
      "\n",
      "How much annual return drag from missing delisted stocks would erase the alpha?\n",
      "\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>annual_drag</th>\n",
       "      <th>alpha_monthly</th>\n",
       "      <th>alpha_annualized</th>\n",
       "      <th>t_stat_ols</th>\n",
       "      <th>t_stat_hac</th>\n",
       "      <th>significant_hac</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>0</th>\n",
       "      <td>0.0%</td>\n",
       "      <td>0.0050</td>\n",
       "      <td>0.0597</td>\n",
       "      <td>3.9777</td>\n",
       "      <td>4.1656</td>\n",
       "      <td>Yes</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>1</th>\n",
       "      <td>0.5%</td>\n",
       "      <td>0.0046</td>\n",
       "      <td>0.0547</td>\n",
       "      <td>3.6444</td>\n",
       "      <td>3.8166</td>\n",
       "      <td>Yes</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2</th>\n",
       "      <td>1.0%</td>\n",
       "      <td>0.0041</td>\n",
       "      <td>0.0497</td>\n",
       "      <td>3.3111</td>\n",
       "      <td>3.4676</td>\n",
       "      <td>Yes</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3</th>\n",
       "      <td>2.0%</td>\n",
       "      <td>0.0033</td>\n",
       "      <td>0.0397</td>\n",
       "      <td>2.6446</td>\n",
       "      <td>2.7695</td>\n",
       "      <td>Yes</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>4</th>\n",
       "      <td>3.0%</td>\n",
       "      <td>0.0025</td>\n",
       "      <td>0.0297</td>\n",
       "      <td>1.9780</td>\n",
       "      <td>2.0714</td>\n",
       "      <td>Yes</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>5</th>\n",
       "      <td>4.0%</td>\n",
       "      <td>0.0016</td>\n",
       "      <td>0.0197</td>\n",
       "      <td>1.3114</td>\n",
       "      <td>1.3734</td>\n",
       "      <td>No</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>6</th>\n",
       "      <td>5.0%</td>\n",
       "      <td>0.0008</td>\n",
       "      <td>0.0097</td>\n",
       "      <td>0.6449</td>\n",
       "      <td>0.6753</td>\n",
       "      <td>No</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "  annual_drag  alpha_monthly  alpha_annualized  t_stat_ols  t_stat_hac  \\\n",
       "0        0.0%         0.0050            0.0597      3.9777      4.1656   \n",
       "1        0.5%         0.0046            0.0547      3.6444      3.8166   \n",
       "2        1.0%         0.0041            0.0497      3.3111      3.4676   \n",
       "3        2.0%         0.0033            0.0397      2.6446      2.7695   \n",
       "4        3.0%         0.0025            0.0297      1.9780      2.0714   \n",
       "5        4.0%         0.0016            0.0197      1.3114      1.3734   \n",
       "6        5.0%         0.0008            0.0097      0.6449      0.6753   \n",
       "\n",
       "  significant_hac  \n",
       "0             Yes  \n",
       "1             Yes  \n",
       "2             Yes  \n",
       "3             Yes  \n",
       "4             Yes  \n",
       "5              No  \n",
       "6              No  "
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "image/png": "[encoded data, 80208 characters]",
      "text/plain": [
       "<Figure size 1000x500 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Survivorship bias sensitivity\n",
    "==================================\n",
    "\"\"\"\n",
    "drags = [0, 0.005, 0.01, 0.02, 0.03, 0.04, 0.05]\n",
    "sensitivity = []\n",
    "\n",
    "# Reuse the aligned FF regression frame built in the alpha cell;\n",
    "# rebuild it if this cell is run standalone.\n",
    "if 'df_reg' not in globals():\n",
    "    _pm = df_port.copy(); _pm.index = _pm.index.to_period('M')\n",
    "    _ff = df_ff[['Mkt-RF', 'SMB', 'HML', 'Mom']].copy(); _ff.index = _ff.index.to_period('M')\n",
    "    df_reg = _pm.join(_ff, how='inner', lsuffix='_port', rsuffix='_ff')\n",
    "    for _c in ['Mkt-RF', 'SMB', 'HML', 'Mom']:\n",
    "        if _c + '_ff' in df_reg.columns:\n",
    "            df_reg[_c] = df_reg[_c + '_ff']\n",
    "    df_reg = df_reg[['long_excess', 'Mkt-RF', 'SMB', 'HML', 'Mom']].dropna().astype(float)\n",
    "\n",
    "X_factors = sm.add_constant(df_reg[['Mkt-RF', 'SMB', 'HML', 'Mom']])\n",
    "\n",
    "for drag in drags:\n",
    "    y_adj = df_reg['long_excess'] - drag / 12  # monthly drag on excess returns\n",
    "    model = sm.OLS(y_adj, X_factors).fit()\n",
    "    model_hac = sm.OLS(y_adj, X_factors).fit(cov_type='HAC', cov_kwds={'maxlags': HAC_LAGS})\n",
    "    alpha_m = model.params['const']\n",
    "    alpha_t = model.tvalues['const']\n",
    "    sensitivity.append({\n",
    "        'annual_drag': f'{drag*100:.1f}%',\n",
    "        'alpha_monthly': alpha_m,\n",
    "        'alpha_annualized': alpha_m * 12,\n",
    "        't_stat_ols': alpha_t,\n",
    "        't_stat_hac': model_hac.tvalues['const'],\n",
    "        'significant_hac': 'Yes' if abs(model_hac.tvalues['const']) > 2 else 'No'\n",
    "    })\n",
    "\n",
    "df_sens = pd.DataFrame(sensitivity)\n",
    "print(\"### Survivorship Bias Sensitivity\\n\")\n",
    "print(\"How much annual return drag from missing delisted stocks would erase the alpha?\\n\")\n",
    "display(df_sens.round(4))\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(10, 5))\n",
    "ax.plot(df_sens['annual_drag'], df_sens['t_stat_ols'], 'o-', color='steelblue', linewidth=2, label='OLS t-stat')\n",
    "ax.plot(df_sens['annual_drag'], df_sens['t_stat_hac'], 'o-', color='seagreen', linewidth=2, label='HAC t-stat')\n",
    "ax.axhline(y=2, color='coral', linestyle='--', label='t = 2 (5% significance)')\n",
    "ax.axhline(y=0, color='black', linewidth=0.5)\n",
    "ax.set_xlabel('Synthetic Annual Return Drag')\n",
    "ax.set_ylabel('FF 4-Factor Alpha t-Statistic')\n",
    "ax.set_title('Survivorship Bias Sensitivity: When Does Alpha Disappear?')\n",
    "ax.legend()\n",
    "ax.grid(alpha=0.3)\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig('../images/04_backtest/survivorship_sensitivity.png', dpi=150, bbox_inches='tight')\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2b3dbdb0",
   "metadata": {},
   "source": [
    "### Concentrating the Drag in Bad Months\n",
    "\n",
    "A flat monthly drag is easy to read, but real delisting losses are not evenly spread through time. They tend to cluster during market stress.\n",
    "\n",
    "So we run a harsher version: apply most of the same annual drag during the worst equal-weight universe months and only a small residual drag elsewhere. If this concentrated drag erases the alpha much faster, the flat-drag test was too generous."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 14,
   "id": "1a9a235a",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T12:20:23.973287Z",
     "iopub.status.busy": "2026-07-31T12:20:23.973043Z",
     "iopub.status.idle": "2026-07-31T12:20:24.014214Z",
     "shell.execute_reply": "2026-07-31T12:20:24.013558Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Concentrated (crash-weighted) drag sensitivity:\n",
      "\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>annual_drag</th>\n",
       "      <th>alpha_annualized</th>\n",
       "      <th>alpha_t_ols</th>\n",
       "      <th>alpha_t_hac</th>\n",
       "      <th>significant_hac_t_gt_2</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>0</th>\n",
       "      <td>0.0%</td>\n",
       "      <td>0.0597</td>\n",
       "      <td>3.9777</td>\n",
       "      <td>4.1656</td>\n",
       "      <td>True</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>1</th>\n",
       "      <td>0.5%</td>\n",
       "      <td>0.0538</td>\n",
       "      <td>3.5921</td>\n",
       "      <td>3.7664</td>\n",
       "      <td>True</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2</th>\n",
       "      <td>1.0%</td>\n",
       "      <td>0.0479</td>\n",
       "      <td>3.2042</td>\n",
       "      <td>3.3635</td>\n",
       "      <td>True</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3</th>\n",
       "      <td>2.0%</td>\n",
       "      <td>0.0362</td>\n",
       "      <td>2.4227</td>\n",
       "      <td>2.5481</td>\n",
       "      <td>True</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>4</th>\n",
       "      <td>3.0%</td>\n",
       "      <td>0.0244</td>\n",
       "      <td>1.6364</td>\n",
       "      <td>1.7237</td>\n",
       "      <td>False</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>5</th>\n",
       "      <td>4.0%</td>\n",
       "      <td>0.0127</td>\n",
       "      <td>0.8484</td>\n",
       "      <td>0.8946</td>\n",
       "      <td>False</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>6</th>\n",
       "      <td>5.0%</td>\n",
       "      <td>0.0009</td>\n",
       "      <td>0.0618</td>\n",
       "      <td>0.0652</td>\n",
       "      <td>False</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "  annual_drag  alpha_annualized  alpha_t_ols  alpha_t_hac  \\\n",
       "0        0.0%            0.0597       3.9777       4.1656   \n",
       "1        0.5%            0.0538       3.5921       3.7664   \n",
       "2        1.0%            0.0479       3.2042       3.3635   \n",
       "3        2.0%            0.0362       2.4227       2.5481   \n",
       "4        3.0%            0.0244       1.6364       1.7237   \n",
       "5        4.0%            0.0127       0.8484       0.8946   \n",
       "6        5.0%            0.0009       0.0618       0.0652   \n",
       "\n",
       "   significant_hac_t_gt_2  \n",
       "0                    True  \n",
       "1                    True  \n",
       "2                    True  \n",
       "3                    True  \n",
       "4                   False  \n",
       "5                   False  \n",
       "6                   False  "
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "\n",
      "Compare the drag level at which significance is lost here against the flat-drag table above. A meaningfully lower breakeven drag under concentration would mean the flat-drag result overstates how robust the alpha is to realistic (crash-clustered) survivorship bias.\n"
     ]
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Concentrated (crash-weighted) survivorship drag sensitivity\n",
    "==================================\n",
    "\"\"\"\n",
    "crash_mask = df_reg.index.map(\n",
    "    lambda p: df_port_pm.loc[p, 'ew_universe'] if p in df_port_pm.index else np.nan\n",
    ")\n",
    "crash_mask = pd.Series(crash_mask, index=df_reg.index)\n",
    "crash_threshold = crash_mask.quantile(0.25)\n",
    "is_crash_month = crash_mask < crash_threshold\n",
    "\n",
    "concentrated_sensitivity = []\n",
    "for drag in drags:\n",
    "    monthly_drag = drag / 12\n",
    "    y_adj = df_reg['long_excess'].copy()\n",
    "    # ~80% of the total drag falls in the worst quartile of months, 20% spread over the rest\n",
    "    n_crash = is_crash_month.sum()\n",
    "    n_other = (~is_crash_month).sum()\n",
    "    if n_crash > 0:\n",
    "        y_adj[is_crash_month] -= monthly_drag * len(y_adj) * 0.8 / n_crash\n",
    "    if n_other > 0:\n",
    "        y_adj[~is_crash_month] -= monthly_drag * len(y_adj) * 0.2 / n_other\n",
    "\n",
    "    model = sm.OLS(y_adj, X_factors).fit()\n",
    "    model_hac = sm.OLS(y_adj, X_factors).fit(cov_type='HAC', cov_kwds={'maxlags': HAC_LAGS})\n",
    "    alpha_m = model.params['const']\n",
    "    concentrated_sensitivity.append({\n",
    "        'annual_drag': f'{drag*100:.1f}%',\n",
    "        'alpha_annualized': alpha_m * 12,\n",
    "        'alpha_t_ols': model.tvalues['const'],\n",
    "        'alpha_t_hac': model_hac.tvalues['const'],\n",
    "        'significant_hac_t_gt_2': abs(model_hac.tvalues['const']) > 2,\n",
    "    })\n",
    "\n",
    "df_concentrated_sensitivity = pd.DataFrame(concentrated_sensitivity)\n",
    "print(\"Concentrated (crash-weighted) drag sensitivity:\\n\")\n",
    "display(df_concentrated_sensitivity.round(4))\n",
    "print(\n",
    "    \"\\nCompare the drag level at which significance is lost here against the flat-drag table above. \"\n",
    "    \"A meaningfully lower breakeven drag under concentration would mean the flat-drag result \"\n",
    "    \"overstates how robust the alpha is to realistic (crash-clustered) survivorship bias.\"\n",
    ")\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 15,
   "id": "93b4cf77",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T12:20:24.016580Z",
     "iopub.status.busy": "2026-07-31T12:20:24.015953Z",
     "iopub.status.idle": "2026-07-31T12:20:24.025025Z",
     "shell.execute_reply": "2026-07-31T12:20:24.024306Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Saved backtest returns to ../data/processed/backtest_returns.csv\n"
     ]
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Save backtest returns for risk decomposition\n",
    "==================================\n",
    "\"\"\"\n",
    "df_port[['long_net', 'short_net', 'ls_net', 'long_excess', 'ls_excess']].to_csv(\n",
    "    '../data/processed/backtest_returns.csv'\n",
    ")\n",
    "\n",
    "print(\"Saved backtest returns to ../data/processed/backtest_returns.csv\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8d50b216",
   "metadata": {},
   "source": [
    "## Robustness: Decile Size and Rebalance Timing\n",
    "\n",
    "The headline portfolio uses one specific set of choices: top 10%, monthly rebalance, equal-weight holdings. Before leaning on the alpha, we should vary the nearby choices that do not require new data.\n",
    "\n",
    "This grid varies:\n",
    "\n",
    "- **Decile cutoff:** 5%, 10%, 15%, 20%.\n",
    "- **Rebalance interval:** every 1, 2, or 3 months.\n",
    "\n",
    "For each combination, we recompute net returns, Sharpe, max drawdown, Fama-French alpha, ordinary OLS t-stat, HAC/Newey-West t-stat, and market beta.\n",
    "\n",
    "This still does not solve universe-construction robustness. The current-constituent universe remains a limitation. But it does answer whether the result depends on exactly one decile/rebalance setting."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 16,
   "id": "970cd763",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T12:20:24.027280Z",
     "iopub.status.busy": "2026-07-31T12:20:24.027058Z",
     "iopub.status.idle": "2026-07-31T12:20:27.934145Z",
     "shell.execute_reply": "2026-07-31T12:20:27.933511Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Long-only robustness across decile size and rebalance interval:\n",
      "\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>decile</th>\n",
       "      <th>rebalance_every_months</th>\n",
       "      <th>n_observations</th>\n",
       "      <th>ann_return</th>\n",
       "      <th>sharpe</th>\n",
       "      <th>max_drawdown</th>\n",
       "      <th>alpha_annualized</th>\n",
       "      <th>alpha_t_ols</th>\n",
       "      <th>alpha_t_hac</th>\n",
       "      <th>mkt_beta</th>\n",
       "      <th>r_squared</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>0</th>\n",
       "      <td>5%</td>\n",
       "      <td>1</td>\n",
       "      <td>239</td>\n",
       "      <td>0.2196</td>\n",
       "      <td>1.0230</td>\n",
       "      <td>-0.5937</td>\n",
       "      <td>0.0788</td>\n",
       "      <td>3.8136</td>\n",
       "      <td>4.0575</td>\n",
       "      <td>1.2460</td>\n",
       "      <td>0.8271</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>1</th>\n",
       "      <td>5%</td>\n",
       "      <td>2</td>\n",
       "      <td>239</td>\n",
       "      <td>0.1984</td>\n",
       "      <td>0.9023</td>\n",
       "      <td>-0.6431</td>\n",
       "      <td>0.0547</td>\n",
       "      <td>2.6498</td>\n",
       "      <td>2.8879</td>\n",
       "      <td>1.2814</td>\n",
       "      <td>0.8355</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2</th>\n",
       "      <td>5%</td>\n",
       "      <td>3</td>\n",
       "      <td>239</td>\n",
       "      <td>0.2165</td>\n",
       "      <td>1.0064</td>\n",
       "      <td>-0.5946</td>\n",
       "      <td>0.0726</td>\n",
       "      <td>3.6577</td>\n",
       "      <td>3.7432</td>\n",
       "      <td>1.2724</td>\n",
       "      <td>0.8410</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3</th>\n",
       "      <td>10%</td>\n",
       "      <td>1</td>\n",
       "      <td>239</td>\n",
       "      <td>0.1948</td>\n",
       "      <td>1.0036</td>\n",
       "      <td>-0.5713</td>\n",
       "      <td>0.0597</td>\n",
       "      <td>3.9777</td>\n",
       "      <td>4.1656</td>\n",
       "      <td>1.1889</td>\n",
       "      <td>0.8885</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>4</th>\n",
       "      <td>10%</td>\n",
       "      <td>2</td>\n",
       "      <td>239</td>\n",
       "      <td>0.1827</td>\n",
       "      <td>0.9256</td>\n",
       "      <td>-0.6137</td>\n",
       "      <td>0.0460</td>\n",
       "      <td>2.9278</td>\n",
       "      <td>2.9244</td>\n",
       "      <td>1.2077</td>\n",
       "      <td>0.8818</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>5</th>\n",
       "      <td>10%</td>\n",
       "      <td>3</td>\n",
       "      <td>239</td>\n",
       "      <td>0.1872</td>\n",
       "      <td>0.9568</td>\n",
       "      <td>-0.5974</td>\n",
       "      <td>0.0489</td>\n",
       "      <td>3.3811</td>\n",
       "      <td>3.1063</td>\n",
       "      <td>1.2160</td>\n",
       "      <td>0.8979</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>6</th>\n",
       "      <td>15%</td>\n",
       "      <td>1</td>\n",
       "      <td>239</td>\n",
       "      <td>0.1825</td>\n",
       "      <td>0.9848</td>\n",
       "      <td>-0.5664</td>\n",
       "      <td>0.0497</td>\n",
       "      <td>3.8816</td>\n",
       "      <td>3.8900</td>\n",
       "      <td>1.1650</td>\n",
       "      <td>0.9109</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>7</th>\n",
       "      <td>15%</td>\n",
       "      <td>2</td>\n",
       "      <td>239</td>\n",
       "      <td>0.1802</td>\n",
       "      <td>0.9652</td>\n",
       "      <td>-0.5650</td>\n",
       "      <td>0.0473</td>\n",
       "      <td>3.7551</td>\n",
       "      <td>3.9190</td>\n",
       "      <td>1.1693</td>\n",
       "      <td>0.9150</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>8</th>\n",
       "      <td>15%</td>\n",
       "      <td>3</td>\n",
       "      <td>239</td>\n",
       "      <td>0.1808</td>\n",
       "      <td>0.9730</td>\n",
       "      <td>-0.5530</td>\n",
       "      <td>0.0477</td>\n",
       "      <td>3.8894</td>\n",
       "      <td>3.8909</td>\n",
       "      <td>1.1682</td>\n",
       "      <td>0.9187</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>9</th>\n",
       "      <td>20%</td>\n",
       "      <td>1</td>\n",
       "      <td>239</td>\n",
       "      <td>0.1745</td>\n",
       "      <td>0.9718</td>\n",
       "      <td>-0.5507</td>\n",
       "      <td>0.0458</td>\n",
       "      <td>3.9347</td>\n",
       "      <td>4.0960</td>\n",
       "      <td>1.1302</td>\n",
       "      <td>0.9217</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>10</th>\n",
       "      <td>20%</td>\n",
       "      <td>2</td>\n",
       "      <td>239</td>\n",
       "      <td>0.1743</td>\n",
       "      <td>0.9658</td>\n",
       "      <td>-0.5454</td>\n",
       "      <td>0.0448</td>\n",
       "      <td>4.0595</td>\n",
       "      <td>4.2899</td>\n",
       "      <td>1.1383</td>\n",
       "      <td>0.9302</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>11</th>\n",
       "      <td>20%</td>\n",
       "      <td>3</td>\n",
       "      <td>239</td>\n",
       "      <td>0.1719</td>\n",
       "      <td>0.9553</td>\n",
       "      <td>-0.5371</td>\n",
       "      <td>0.0422</td>\n",
       "      <td>3.8093</td>\n",
       "      <td>3.8434</td>\n",
       "      <td>1.1366</td>\n",
       "      <td>0.9294</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "   decile  rebalance_every_months  n_observations  ann_return  sharpe  \\\n",
       "0      5%                       1             239      0.2196  1.0230   \n",
       "1      5%                       2             239      0.1984  0.9023   \n",
       "2      5%                       3             239      0.2165  1.0064   \n",
       "3     10%                       1             239      0.1948  1.0036   \n",
       "4     10%                       2             239      0.1827  0.9256   \n",
       "5     10%                       3             239      0.1872  0.9568   \n",
       "6     15%                       1             239      0.1825  0.9848   \n",
       "7     15%                       2             239      0.1802  0.9652   \n",
       "8     15%                       3             239      0.1808  0.9730   \n",
       "9     20%                       1             239      0.1745  0.9718   \n",
       "10    20%                       2             239      0.1743  0.9658   \n",
       "11    20%                       3             239      0.1719  0.9553   \n",
       "\n",
       "    max_drawdown  alpha_annualized  alpha_t_ols  alpha_t_hac  mkt_beta  \\\n",
       "0        -0.5937            0.0788       3.8136       4.0575    1.2460   \n",
       "1        -0.6431            0.0547       2.6498       2.8879    1.2814   \n",
       "2        -0.5946            0.0726       3.6577       3.7432    1.2724   \n",
       "3        -0.5713            0.0597       3.9777       4.1656    1.1889   \n",
       "4        -0.6137            0.0460       2.9278       2.9244    1.2077   \n",
       "5        -0.5974            0.0489       3.3811       3.1063    1.2160   \n",
       "6        -0.5664            0.0497       3.8816       3.8900    1.1650   \n",
       "7        -0.5650            0.0473       3.7551       3.9190    1.1693   \n",
       "8        -0.5530            0.0477       3.8894       3.8909    1.1682   \n",
       "9        -0.5507            0.0458       3.9347       4.0960    1.1302   \n",
       "10       -0.5454            0.0448       4.0595       4.2899    1.1383   \n",
       "11       -0.5371            0.0422       3.8093       3.8434    1.1366   \n",
       "\n",
       "    r_squared  \n",
       "0      0.8271  \n",
       "1      0.8355  \n",
       "2      0.8410  \n",
       "3      0.8885  \n",
       "4      0.8818  \n",
       "5      0.8979  \n",
       "6      0.9109  \n",
       "7      0.9150  \n",
       "8      0.9187  \n",
       "9      0.9217  \n",
       "10     0.9302  \n",
       "11     0.9294  "
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "image/png": "[encoded data, 106388 characters]",
      "text/plain": [
       "<Figure size 1300x500 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Headline setting from grid:\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>decile</th>\n",
       "      <th>rebalance_every_months</th>\n",
       "      <th>n_observations</th>\n",
       "      <th>ann_return</th>\n",
       "      <th>sharpe</th>\n",
       "      <th>max_drawdown</th>\n",
       "      <th>alpha_annualized</th>\n",
       "      <th>alpha_t_ols</th>\n",
       "      <th>alpha_t_hac</th>\n",
       "      <th>mkt_beta</th>\n",
       "      <th>r_squared</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>3</th>\n",
       "      <td>10%</td>\n",
       "      <td>1</td>\n",
       "      <td>239</td>\n",
       "      <td>0.1948</td>\n",
       "      <td>1.0036</td>\n",
       "      <td>-0.5713</td>\n",
       "      <td>0.0597</td>\n",
       "      <td>3.9777</td>\n",
       "      <td>4.1656</td>\n",
       "      <td>1.1889</td>\n",
       "      <td>0.8885</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "  decile  rebalance_every_months  n_observations  ann_return  sharpe  \\\n",
       "3    10%                       1             239      0.1948  1.0036   \n",
       "\n",
       "   max_drawdown  alpha_annualized  alpha_t_ols  alpha_t_hac  mkt_beta  \\\n",
       "3       -0.5713            0.0597       3.9777       4.1656    1.1889   \n",
       "\n",
       "   r_squared  \n",
       "3     0.8885  "
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "\n",
      "Interpretation: robust results should not require exactly one cutoff or one rebalance calendar. This grid still uses the same current-constituent universe, so universe robustness remains unresolved.\n"
     ]
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Robustness sweep: decile size × rebalance interval, with FF alpha\n",
    "==================================\n",
    "\"\"\"\n",
    "decile_grid = [0.05, 0.10, 0.15, 0.20]\n",
    "rebalance_grid = [1, 2, 3]\n",
    "tc_rate = TRANSACTION_COST_BPS / 10000\n",
    "\n",
    "\n",
    "def form_decile_portfolios_interval(signal_df, return_df, decile=0.1, rebalance_every=1):\n",
    "    \"\"\"Long-only top-decile portfolio that rebalances every N months and holds between rebalances.\"\"\"\n",
    "    common_dates = signal_df.index.intersection(return_df.index)\n",
    "    common_tickers = signal_df.columns.intersection(return_df.columns)\n",
    "    signal_df = signal_df.loc[common_dates, common_tickers]\n",
    "    return_df = return_df.loc[common_dates, common_tickers]\n",
    "\n",
    "    rows = []\n",
    "    rebalance_dates = []\n",
    "    current_holdings = None\n",
    "    prev_w = None\n",
    "\n",
    "    for i in range(len(common_dates) - 1):\n",
    "        date = common_dates[i]\n",
    "        next_date = common_dates[i + 1]\n",
    "        should_rebalance = current_holdings is None or (i % rebalance_every == 0)\n",
    "\n",
    "        if should_rebalance:\n",
    "            scores = signal_df.loc[date].dropna()\n",
    "            if len(scores) < 50:\n",
    "                continue\n",
    "            n_long = max(int(len(scores) * decile), 1)\n",
    "            current_holdings = scores.sort_values(ascending=False).head(n_long).index.tolist()\n",
    "            curr_w = _equal_weight_vector(current_holdings)\n",
    "            if prev_w is None or prev_w.empty:\n",
    "                turnover = curr_w.abs().sum()\n",
    "            else:\n",
    "                names = prev_w.index.union(curr_w.index)\n",
    "                turnover = (curr_w.reindex(names, fill_value=0.0) - prev_w.reindex(names, fill_value=0.0)).abs().sum() / 2.0\n",
    "            prev_w = curr_w\n",
    "        else:\n",
    "            turnover = 0.0\n",
    "\n",
    "        next_rets = return_df.loc[next_date, current_holdings].dropna()\n",
    "        if len(next_rets) == 0:\n",
    "            continue\n",
    "        long_ret = next_rets.mean()\n",
    "        rows.append({\n",
    "            'long': long_ret,\n",
    "            'long_holdings': list(current_holdings),\n",
    "            'long_turnover': float(turnover),\n",
    "        })\n",
    "        rebalance_dates.append(next_date)\n",
    "\n",
    "    out = pd.DataFrame(rows, index=pd.DatetimeIndex(rebalance_dates))\n",
    "    out['long_net'] = out['long'] - out['long_turnover'] * tc_rate\n",
    "    return out\n",
    "\n",
    "\n",
    "def ff_alpha_for_returns(return_series):\n",
    "    \"\"\"FF alpha diagnostics for a long-only net return series.\"\"\"\n",
    "    ret = return_series.rename('long_net').dropna().to_frame()\n",
    "    ret.index = ret.index.to_period('M')\n",
    "    ff_local = df_ff[['RF'] + FF_FACTOR_COLS].copy()\n",
    "    ff_local.index = ff_local.index.to_period('M')\n",
    "    reg = ret.join(ff_local, how='inner').dropna().astype(float)\n",
    "    if len(reg) < 24:\n",
    "        return pd.Series({\n",
    "            'alpha_annualized': np.nan,\n",
    "            'alpha_t_ols': np.nan,\n",
    "            'alpha_t_hac': np.nan,\n",
    "            'mkt_beta': np.nan,\n",
    "            'r_squared': np.nan,\n",
    "        })\n",
    "    y = reg['long_net'] - reg['RF']\n",
    "    X = sm.add_constant(reg[FF_FACTOR_COLS], has_constant='add')\n",
    "    model = sm.OLS(y, X).fit()\n",
    "    model_hac = sm.OLS(y, X).fit(cov_type='HAC', cov_kwds={'maxlags': HAC_LAGS})\n",
    "    return pd.Series({\n",
    "        'alpha_annualized': model.params['const'] * 12,\n",
    "        'alpha_t_ols': model.tvalues['const'],\n",
    "        'alpha_t_hac': model_hac.tvalues['const'],\n",
    "        'mkt_beta': model.params['Mkt-RF'],\n",
    "        'r_squared': model.rsquared,\n",
    "    })\n",
    "\n",
    "robustness_rows = []\n",
    "for d in decile_grid:\n",
    "    for rebalance_every in rebalance_grid:\n",
    "        df_port_d = form_decile_portfolios_interval(\n",
    "            df_momentum, df_returns, decile=d, rebalance_every=rebalance_every\n",
    "        )\n",
    "        m = performance_metrics(df_port_d['long_net'])\n",
    "        alpha_diag = ff_alpha_for_returns(df_port_d['long_net'])\n",
    "        robustness_rows.append({\n",
    "            'decile': f'{int(d*100)}%',\n",
    "            'rebalance_every_months': rebalance_every,\n",
    "            'n_observations': len(df_port_d),\n",
    "            'ann_return': m['ann_return'],\n",
    "            'sharpe': m['sharpe'],\n",
    "            'max_drawdown': m['max_drawdown'],\n",
    "            **alpha_diag.to_dict(),\n",
    "        })\n",
    "\n",
    "df_robustness = pd.DataFrame(robustness_rows)\n",
    "print(\"Long-only robustness across decile size and rebalance interval:\\n\")\n",
    "display(df_robustness.round(4))\n",
    "\n",
    "fig, axes = plt.subplots(1, 2, figsize=(13, 5))\n",
    "for rebalance_every in rebalance_grid:\n",
    "    sub = df_robustness[df_robustness['rebalance_every_months'] == rebalance_every]\n",
    "    axes[0].plot(sub['decile'], sub['sharpe'], marker='o', label=f'every {rebalance_every}m')\n",
    "    axes[1].plot(sub['decile'], sub['alpha_t_hac'], marker='o', label=f'every {rebalance_every}m')\n",
    "\n",
    "axes[0].set_ylabel('Sharpe ratio')\n",
    "axes[0].set_title('Sharpe Across Robustness Grid')\n",
    "axes[0].grid(alpha=0.3)\n",
    "axes[1].axhline(y=2, color='coral', linestyle='--', linewidth=1, label='t = 2')\n",
    "axes[1].set_ylabel('HAC alpha t-stat')\n",
    "axes[1].set_title('FF Alpha Significance Across Grid')\n",
    "axes[1].grid(alpha=0.3)\n",
    "for ax in axes:\n",
    "    ax.set_xlabel('Long cutoff')\n",
    "    ax.legend(fontsize=8)\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig('../images/04_backtest/robustness_grid.png', dpi=150, bbox_inches='tight')\n",
    "plt.show()\n",
    "\n",
    "headline_row = df_robustness[\n",
    "    (df_robustness['decile'] == '10%') &\n",
    "    (df_robustness['rebalance_every_months'] == 1)\n",
    "]\n",
    "if not headline_row.empty:\n",
    "    print(\"Headline setting from grid:\")\n",
    "    display(headline_row.round(4))\n",
    "\n",
    "print(\n",
    "    \"\\nInterpretation: robust results should not require exactly one cutoff or one rebalance calendar. \"\n",
    "    \"This grid still uses the same current-constituent universe, so universe robustness remains unresolved.\"\n",
    ")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "75addbfc",
   "metadata": {},
   "source": [
    "## Conclusion\n",
    "\n",
    "The main result is that a sector-neutralized momentum signal, traded long-only, produces a positive Fama-French alpha in this dataset. The result is more nuanced than the raw headline number:\n",
    "\n",
    "- **Long-only momentum:** Fama-French 4-factor alpha is about **5.97% annualized**. The ordinary OLS t-stat is **3.98**, and the HAC/Newey-West t-stat is **4.17**, so the result is not weakened by this simple autocorrelation/heteroskedasticity check.\n",
    "- **Market beta:** the long-only book has market beta around **1.19**, so much of the raw active return versus the equal-weight universe is market exposure. The FF alpha is the cleaner result because it controls for that beta.\n",
    "- **Walk-forward:** long-only Sharpe is positive in all four 5-year windows, but active return versus the equal-weight universe is positive in only three of four. The weak window is 2006-2011, which includes the 2008-09 momentum crash.\n",
    "- **Survivorship sensitivity:** the alpha survives **2%** annual synthetic drag under both flat and crash-concentrated assumptions. At **3%**, the flat drag is borderline, while the crash-concentrated drag loses significance. This is a breakeven stress test, not a true replacement for survivorship-free data.\n",
    "- **Robustness grid:** nearby decile and rebalance choices are checked with Sharpe and FF alpha. Alpha remains positive and HAC-significant across the tested 5-20% cutoffs and 1/2/3-month rebalance intervals, though universe-construction robustness is still unresolved.\n",
    "- **Long-short:** the long-short portfolio does not produce a significant alpha. In this universe, the useful part of the signal is concentrated on the long side; the short side adds noise.\n",
    "\n",
    "The next notebook decomposes the portfolio's risk, the quadratic form $w^\\top\\Sigma w$, into systematic and idiosyncratic components with PCA."
   ]
  }
 ],
 "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
}
