{ "cells": [ { "cell_type": "markdown", "id": "iq6umm6jyv", "metadata": {}, "source": [ "# Scenario Stress Testing\n", "\n", "This notebook demonstrates the stress-testing and performance utilities:\n", "\n", "1. Build a long-only mean-variance **tangency portfolio** from weekly ETF returns.\n", "2. Generate a **performance report** with VaR/CVaR at 95% confidence.\n", "3. Apply **exponential-decay** stress (half-life weighting).\n", "4. Apply **kernel-focus** stress targeting high-volatility regimes.\n", "5. Combine a **linear scenario shock** with reweighted probabilities." ] }, { "cell_type": "code", "execution_count": 1, "id": "upc389g2p19", "metadata": { "execution": { "iopub.execute_input": "2026-03-30T19:38:52.625735Z", "iopub.status.busy": "2026-03-30T19:38:52.625391Z", "iopub.status.idle": "2026-03-30T19:38:53.122010Z", "shell.execute_reply": "2026-03-30T19:38:53.121706Z" } }, "outputs": [], "source": [ "import numpy as np\n", "import pandas as pd\n", "from pyvallocation import PortfolioWrapper\n", "from pyvallocation.stress import (\n", " exp_decay_stress,\n", " kernel_focus_stress,\n", " linear_map,\n", " stress_test,\n", ")\n", "from pyvallocation.utils.performance import performance_report" ] }, { "cell_type": "markdown", "id": "59gwvkzk8w4", "metadata": {}, "source": [ "## Load weekly returns and build a tangency portfolio" ] }, { "cell_type": "code", "execution_count": 2, "id": "nooyhjl0rd", "metadata": { "execution": { "iopub.execute_input": "2026-03-30T19:38:53.123562Z", "iopub.status.busy": "2026-03-30T19:38:53.123463Z", "iopub.status.idle": "2026-03-30T19:38:53.138627Z", "shell.execute_reply": "2026-03-30T19:38:53.138412Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Assets : ['DBC', 'GLD', 'SPY', 'TLT']\n", "Weeks : 1006\n" ] } ], "source": [ "from pathlib import Path\n", "\n", "_candidates = [\n", " Path(\"examples/ETF_prices.csv\"),\n", " Path(\"../examples/ETF_prices.csv\"),\n", " Path(\"../../examples/ETF_prices.csv\"),\n", " Path(\"../../../examples/ETF_prices.csv\"),\n", "]\n", "_csv = next((p for p in _candidates if p.exists()), None)\n", "if _csv is None:\n", " raise FileNotFoundError(\"ETF_prices.csv not found\")\n", "prices = pd.read_csv(_csv, index_col=\"Date\", parse_dates=True)\n", "prices = prices.dropna(how=\"all\").ffill()\n", "weekly = prices.resample(\"W-FRI\").last().dropna(how=\"all\")\n", "returns = weekly.pct_change().dropna()\n", "returns = returns.rename(columns=lambda c: c.replace(\" \", \"_\"))\n", "\n", "print(f\"Assets : {list(returns.columns)}\")\n", "print(f\"Weeks : {len(returns)}\")" ] }, { "cell_type": "code", "execution_count": 3, "id": "gyac04d04v", "metadata": { "execution": { "iopub.execute_input": "2026-03-30T19:38:53.139737Z", "iopub.status.busy": "2026-03-30T19:38:53.139669Z", "iopub.status.idle": "2026-03-30T19:38:53.314028Z", "shell.execute_reply": "2026-03-30T19:38:53.313797Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Tangency portfolio weights:\n", "DBC 0.0\n", "GLD 0.0\n", "SPY 1.0\n", "TLT 0.0\n", "Name: Tangency Portfolio (rf=1.00%), dtype: float64\n" ] } ], "source": [ "wrapper = PortfolioWrapper.from_moments(returns.mean(), returns.cov())\n", "frontier = wrapper.variance_frontier(num_portfolios=25)\n", "w_series, _, _ = frontier.tangency(risk_free_rate=0.01)\n", "\n", "# Ensure weights are a 1-D numpy array for all downstream stress calls\n", "weights = w_series.values.ravel()\n", "\n", "print(\"Tangency portfolio weights:\")\n", "print(w_series.round(4))" ] }, { "cell_type": "markdown", "id": "y6lhil1yqj", "metadata": {}, "source": [ "## Nominal performance report\n", "\n", "`performance_report` computes mean, volatility, VaR, and CVaR at the\n", "specified confidence level under uniform scenario probabilities." ] }, { "cell_type": "code", "execution_count": 4, "id": "n34ioeqvoi", "metadata": { "execution": { "iopub.execute_input": "2026-03-30T19:38:53.315158Z", "iopub.status.busy": "2026-03-30T19:38:53.315096Z", "iopub.status.idle": "2026-03-30T19:38:53.317550Z", "shell.execute_reply": "2026-03-30T19:38:53.317357Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "=== Nominal Performance ===\n", "mean 0.0022\n", "stdev 0.0251\n", "VaR95 0.0390\n", "CVaR95 0.0603\n", "ENS 1006.0000\n", "dtype: float64\n" ] } ], "source": [ "report = performance_report(weights, returns.values, confidence=0.95)\n", "\n", "print(\"=== Nominal Performance ===\")\n", "print(report.round(4))" ] }, { "cell_type": "markdown", "id": "t8gnejeega", "metadata": {}, "source": [ "## Exponential-decay stress\n", "\n", "Recent scenarios receive more weight with a 60-week half-life, simulating\n", "a regime where recent market conditions persist." ] }, { "cell_type": "code", "execution_count": 5, "id": "dys95jajcm4", "metadata": { "execution": { "iopub.execute_input": "2026-03-30T19:38:53.318540Z", "iopub.status.busy": "2026-03-30T19:38:53.318479Z", "iopub.status.idle": "2026-03-30T19:38:53.322000Z", "shell.execute_reply": "2026-03-30T19:38:53.321824Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "=== Half-Life Stress (60 weeks) ===\n", " return_nom stdev_nom VaR95_nom CVaR95_nom ENS_nom \\\n", "portfolio_0 0.0022 0.0251 0.039 0.0603 1006.0 \n", "\n", " return_stress stdev_stress VaR95_stress CVaR95_stress \\\n", "portfolio_0 0.0027 0.0246 0.0307 0.0546 \n", "\n", " ENS_stress KL_q_p \n", "portfolio_0 235.2738 1.453 \n" ] } ], "source": [ "df_half_life = exp_decay_stress(\n", " weights, returns.values, half_life=60\n", ")\n", "\n", "print(\"=== Half-Life Stress (60 weeks) ===\")\n", "print(df_half_life.round(4))" ] }, { "cell_type": "markdown", "id": "xparq3usnya", "metadata": {}, "source": [ "## Kernel-focus stress\n", "\n", "Focus on scenarios where SPY rolling volatility is at its maximum,\n", "tilting probabilities toward high-volatility regimes." ] }, { "cell_type": "code", "execution_count": 6, "id": "tupnrg6d6l", "metadata": { "execution": { "iopub.execute_input": "2026-03-30T19:38:53.323038Z", "iopub.status.busy": "2026-03-30T19:38:53.322954Z", "iopub.status.idle": "2026-03-30T19:38:53.326982Z", "shell.execute_reply": "2026-03-30T19:38:53.326779Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "=== Kernel Focus Stress (SPY high-vol regime) ===\n", " return_nom stdev_nom VaR95_nom CVaR95_nom ENS_nom \\\n", "portfolio_0 0.0022 0.0251 0.039 0.0603 1006.0 \n", "\n", " return_stress stdev_stress VaR95_stress CVaR95_stress \\\n", "portfolio_0 0.0233 0.0576 0.024 0.0241 \n", "\n", " ENS_stress KL_q_p \n", "portfolio_0 5.1206 5.2805 \n" ] } ], "source": [ "focus = returns[\"SPY\"].rolling(12).std(ddof=0).bfill()\n", "\n", "df_kernel = kernel_focus_stress(\n", " weights,\n", " returns.values,\n", " focus_series=focus.values,\n", " bandwidth=None,\n", " target=focus.values.max(),\n", ")\n", "\n", "print(\"=== Kernel Focus Stress (SPY high-vol regime) ===\")\n", "print(df_kernel.round(4))" ] }, { "cell_type": "markdown", "id": "0c9vcym7ejhg", "metadata": {}, "source": [ "## Combined scenario shock with `stress_test`\n", "\n", "Apply a 25% scale-up to all scenarios and reweight probabilities to\n", "favour early (tail) scenarios." ] }, { "cell_type": "code", "execution_count": 7, "id": "3o00pr7orul", "metadata": { "execution": { "iopub.execute_input": "2026-03-30T19:38:53.327962Z", "iopub.status.busy": "2026-03-30T19:38:53.327907Z", "iopub.status.idle": "2026-03-30T19:38:53.331093Z", "shell.execute_reply": "2026-03-30T19:38:53.330923Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "=== Combined Shock + Reweight ===\n", " return_nom stdev_nom VaR95_nom CVaR95_nom ENS_nom \\\n", "portfolio_0 0.0022 0.0251 0.039 0.0603 1006.0 \n", "\n", " return_stress stdev_stress VaR95_stress CVaR95_stress \\\n", "portfolio_0 0.0029 0.031 0.0479 0.0741 \n", "\n", " ENS_stress KL_q_p \n", "portfolio_0 987.2946 0.0188 \n" ] } ], "source": [ "scale_up = linear_map(scale=1.25)\n", "\n", "tail_weights = np.linspace(1.0, 2.0, num=returns.shape[0])\n", "tail_weights /= tail_weights.sum()\n", "\n", "df_combo = stress_test(\n", " weights,\n", " returns.values,\n", " stressed_probabilities=tail_weights,\n", " transform=scale_up,\n", ")\n", "\n", "print(\"=== Combined Shock + Reweight ===\")\n", "print(df_combo.round(4))" ] }, { "cell_type": "markdown", "id": "e2xe289vxq9", "metadata": {}, "source": [ "## Summary table\n", "\n", "Collect all stress results into a single comparison table." ] }, { "cell_type": "code", "execution_count": 8, "id": "2uam0d8w385", "metadata": { "execution": { "iopub.execute_input": "2026-03-30T19:38:53.332110Z", "iopub.status.busy": "2026-03-30T19:38:53.332046Z", "iopub.status.idle": "2026-03-30T19:38:53.335931Z", "shell.execute_reply": "2026-03-30T19:38:53.335752Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "=== Summary ===\n", "Nominal:\n", "mean 0.0022\n", "stdev 0.0251\n", "VaR95 0.0390\n", "CVaR95 0.0603\n", "ENS 1006.0000\n", "dtype: float64\n", "\n", "Exp-Decay (HL=60):\n", " return_nom stdev_nom VaR95_nom CVaR95_nom ENS_nom \\\n", "portfolio_0 0.0022 0.0251 0.039 0.0603 1006.0 \n", "\n", " return_stress stdev_stress VaR95_stress CVaR95_stress \\\n", "portfolio_0 0.0027 0.0246 0.0307 0.0546 \n", "\n", " ENS_stress KL_q_p \n", "portfolio_0 235.2738 1.453 \n", "\n", "Kernel Focus (SPY vol):\n", " return_nom stdev_nom VaR95_nom CVaR95_nom ENS_nom \\\n", "portfolio_0 0.0022 0.0251 0.039 0.0603 1006.0 \n", "\n", " return_stress stdev_stress VaR95_stress CVaR95_stress \\\n", "portfolio_0 0.0233 0.0576 0.024 0.0241 \n", "\n", " ENS_stress KL_q_p \n", "portfolio_0 5.1206 5.2805 \n", "\n", "Shock + Reweight:\n", " return_nom stdev_nom VaR95_nom CVaR95_nom ENS_nom \\\n", "portfolio_0 0.0022 0.0251 0.039 0.0603 1006.0 \n", "\n", " return_stress stdev_stress VaR95_stress CVaR95_stress \\\n", "portfolio_0 0.0029 0.031 0.0479 0.0741 \n", "\n", " ENS_stress KL_q_p \n", "portfolio_0 987.2946 0.0188 \n" ] } ], "source": [ "print(\"=== Summary ===\")\n", "print(\"Nominal:\")\n", "print(report.round(4))\n", "print()\n", "print(\"Exp-Decay (HL=60):\")\n", "print(df_half_life.round(4))\n", "print()\n", "print(\"Kernel Focus (SPY vol):\")\n", "print(df_kernel.round(4))\n", "print()\n", "print(\"Shock + Reweight:\")\n", "print(df_combo.round(4))" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "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.12.9" } }, "nbformat": 4, "nbformat_minor": 5 }