Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions portfolio_optimization/QP_portfolio_frontier_duals.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "f330297d",
"metadata": {},
"source": "# Portfolio Optimization \u2014 the Frontier via the Skill, + Shadow Prices (cuOpt QP)\n\nThe base `QP_portfolio_optimization` notebook **hand-codes** an efficient-frontier sweep (a manual loop over target returns). This sibling shows that following the `cuopt-multi-objective-exploration` skill **recreates that frontier as a named, systematic workflow** \u2014 anchor each objective \u2192 \u03b5-constraint sweep (the return floor is the parametric bound) \u2192 filter dominated \u2192 read the frontier \u2014 with less ad-hoc scaffolding, and **adds the one thing the manual sweep omits**: the return-constraint **dual** (shadow price d(variance)/d(return)).\n\nSo the two examples are complementary tests of the skill: here it **reproduces** an existing frontier (return vs risk) with less manual work and surfaces the duals; the workforce MILP (`workforce_optimization/workforce_optimization_multiobjective.ipynb`) is the **net-new** case \u2014 and the deliberate contrast is that **a QP has constraint duals, an integer program does not.**"
},
{
"cell_type": "markdown",
"id": "39af174e",
"metadata": {},
"source": "## Environment Setup"
},
{
"cell_type": "code",
"id": "dd4db594",
"metadata": {},
"execution_count": null,
"outputs": [],
"source": "import subprocess\nimport html\nfrom IPython.display import display, HTML\n\ndef check_gpu():\n try:\n result = subprocess.run([\"nvidia-smi\"], capture_output=True, text=True, timeout=5)\n result.check_returncode()\n lines = result.stdout.splitlines()\n gpu_info = lines[2] if len(lines) > 2 else \"GPU detected\"\n gpu_info_escaped = html.escape(gpu_info)\n display(HTML(f\"\"\"\n <div style=\"border:2px solid #4CAF50;padding:10px;border-radius:10px;background:#e8f5e9;\">\n <h3>\u2705 GPU is enabled</h3>\n <pre>{gpu_info_escaped}</pre>\n </div>\n \"\"\"))\n return True\n except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError, IndexError) as e:\n display(HTML(\"\"\"\n <div style=\"border:2px solid red;padding:15px;border-radius:10px;background:#ffeeee;\">\n <h3>\u26a0\ufe0f GPU not detected!</h3>\n <p>This notebook requires a <b>GPU runtime</b>.</p>\n\n <h4>If running in Google Colab:</h4>\n <ol>\n <li>Click on <b>Runtime \u2192 Change runtime type</b></li>\n <li>Set <b>Hardware accelerator</b> to <b>GPU</b></li>\n <li>Then click <b>Save</b> and <b>Runtime \u2192 Restart runtime</b>.</li>\n </ol>\n\n <h4>If running in Docker:</h4>\n <ol>\n <li>Ensure you have <b>NVIDIA Docker runtime</b> installed (<code>nvidia-docker2</code>)</li>\n <li>Run container with GPU support: <code>docker run --gpus all ...</code></li>\n <li>Or use: <code>docker run --runtime=nvidia ...</code> for older Docker versions</li>\n <li>Verify GPU access: <code>docker run --gpus all nvidia/cuda:12.0.0-base-ubuntu22.04 nvidia-smi</code></li>\n </ol>\n\n <p><b>Additional resources:</b></p>\n <ul>\n <li><a href=\"https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html\" target=\"_blank\">NVIDIA Container Toolkit Installation Guide</a></li>\n </ul>\n </div>\n \"\"\"))\n return False\n\ncheck_gpu()"
},
{
"cell_type": "code",
"id": "258c4642",
"metadata": {},
"execution_count": null,
"outputs": [],
"source": "# Uncomment for your CUDA version if cuOpt is not already installed (e.g., Google Colab):\n# !pip install --upgrade --extra-index-url https://pypi.nvidia.com cuopt-cu12 # CUDA 12\n# !pip install --upgrade --extra-index-url https://pypi.nvidia.com cuopt-cu13 # CUDA 13"
},
{
"cell_type": "code",
"id": "91a1a66e",
"metadata": {},
"execution_count": null,
"outputs": [],
"source": "import numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nfrom cuopt.linear_programming.problem import Problem, QuadraticExpression, MINIMIZE\nprint(\"Imports ready (cuOpt QP solver)\")"
},
{
"cell_type": "markdown",
"id": "16e2d023",
"metadata": {},
"source": "## Data\n\nSame simulated asset universe as `QP_portfolio_optimization` (annualized mean returns + covariance)."
},
{
"cell_type": "code",
"id": "8b8b0bde",
"metadata": {},
"execution_count": null,
"outputs": [],
"source": "# Simulate monthly returns with realistic assumptions\nnp.random.seed(7)\n\nassets = [\"Cash\", \"US Equity\", \"Intl Equity\", \"Bond\", \"REIT/Gold\"]\n\nannual_mean = np.array([0.02, 0.08, 0.075, 0.04, 0.06])\nannual_vol = np.array([0.005, 0.16, 0.18, 0.06, 0.14])\n\ncorr = np.array([\n [1.00, 0.05, 0.05, 0.10, 0.05],\n [0.05, 1.00, 0.80, -0.10, 0.55],\n [0.05, 0.80, 1.00, -0.05, 0.50],\n [0.10, -0.10, -0.05, 1.00, 0.00],\n [0.05, 0.55, 0.50, 0.00, 1.00],\n])\n\nmonthly_mean = annual_mean / 12.0\nmonthly_vol = annual_vol / np.sqrt(12.0)\nmonthly_cov = np.outer(monthly_vol, monthly_vol) * corr\n\nn_months = 120\nreturns = np.random.multivariate_normal(monthly_mean, monthly_cov, size=n_months)\n\n# Estimate annualized mean and covariance from the simulated data\nmean_returns = returns.mean(axis=0) * 12.0\ncov_matrix = np.cov(returns, rowvar=False) * 12.0\n\nsummary = pd.DataFrame(\n {\n \"Annualized Return\": mean_returns,\n \"Annualized Volatility\": np.sqrt(np.diag(cov_matrix)),\n },\n index=assets,\n)\n\nsummary.style.format({\"Annualized Return\": \"{:.2%}\", \"Annualized Volatility\": \"{:.2%}\"})"
},
{
"cell_type": "markdown",
"id": "e5004295",
"metadata": {},
"source": "## Min-variance QP with the return-constraint dual\n\nThis is the base notebook's `solve_min_variance_qp`, with one addition: we keep a handle on the `min_return` constraint and read its **`.DualValue`** after the solve. For the QP, that dual is the shadow price d(variance)/d(return)."
},
{
"cell_type": "code",
"id": "44ad356a",
"metadata": {},
"execution_count": null,
"outputs": [],
"source": "def solve_min_variance_qp_dual(cov_matrix, mean_returns, target_return=None, max_weight=None):\n n = len(mean_returns)\n prob = Problem(\"Portfolio_Optimization\")\n ub = max_weight if max_weight is not None else 1.0\n w = [prob.addVariable(lb=0.0, ub=ub, name=f\"w_{i}\") for i in range(n)]\n\n quad = None\n for i in range(n):\n for j in range(n):\n c = float(cov_matrix[i, j])\n if abs(c) > 1e-12:\n term = c * w[i] * w[j]\n quad = term if quad is None else quad + term\n prob.setObjective(quad, sense=MINIMIZE)\n\n prob.addConstraint(sum(w) == 1, name=\"fully_invested\")\n ret_con = None\n if target_return is not None:\n ret_expr = sum(float(mean_returns[i]) * w[i] for i in range(n))\n ret_con = prob.addConstraint(ret_expr >= float(target_return), name=\"min_return\")\n\n prob.solve()\n status = prob.Status.name if hasattr(prob.Status, \"name\") else str(prob.Status)\n weights = np.array([w[i].Value for i in range(n)])\n port_ret = float(mean_returns @ weights)\n port_vol = float(np.sqrt(max(weights @ cov_matrix @ weights, 0.0)))\n dual = abs(float(ret_con.DualValue)) if ret_con is not None else 0.0 # shadow price d(var)/d(return)\n return {\"weights\": weights, \"ret\": port_ret, \"vol\": port_vol, \"dual\": dual, \"status\": status}\n\nmv = solve_min_variance_qp_dual(cov_matrix, mean_returns)\nprint(f\"Min-variance: status={mv['status']}, return={mv['ret']:.2%}, vol={mv['vol']:.2%}\")"
},
{
"cell_type": "markdown",
"id": "2a1a84e1",
"metadata": {},
"source": "## Sweep the return target \u2192 frontier + shadow price\n\nThe \u03b5-constraint sweep (return floor as the parametric bound), capturing the dual at each point. The skill's note applies: cuOpt's QP beta is PDLP (a first-order method), so the dual is accurate **to the solver's tolerance** \u2014 we keep points the solver reports as `Optimal` and flag any `PrimalFeasible`."
},
{
"cell_type": "code",
"id": "5098fd42",
"metadata": {},
"execution_count": null,
"outputs": [],
"source": "min_ret = mv[\"ret\"]\nmax_ret = float(mean_returns.max())\ntargets = np.linspace(min_ret, max_ret * 0.999, 25)\n\nrets, vols, duals, flagged = [], [], [], 0\nfor t in targets:\n r = solve_min_variance_qp_dual(cov_matrix, mean_returns, target_return=t)\n if r[\"status\"] not in (\"Optimal\", \"PrimalFeasible\"):\n continue\n if r[\"status\"] != \"Optimal\":\n flagged += 1\n rets.append(r[\"ret\"]); vols.append(r[\"vol\"]); duals.append(r[\"dual\"])\n\nrets, vols, duals = map(np.array, (rets, vols, duals))\nprint(f\"Frontier points: {len(rets)} | not certified-Optimal (PrimalFeasible): {flagged}\")\nprint(f\"Shadow price d(variance)/d(return): {duals.min():.3f} -> {duals.max():.3f} as required return rises\")"
},
{
"cell_type": "code",
"id": "4f051b88",
"metadata": {},
"execution_count": null,
"outputs": [],
"source": "fig, axes = plt.subplots(1, 2, figsize=(13, 5))\naxes[0].plot(vols * 100, rets * 100, \"o-\", color=\"navy\", lw=1.6)\naxes[0].set_xlabel(\"Volatility (%)\"); axes[0].set_ylabel(\"Expected Return (%)\")\naxes[0].set_title(\"Efficient frontier (return vs risk)\"); axes[0].grid(alpha=0.3)\n\naxes[1].plot(rets * 100, duals, \"o-\", color=\"purple\", lw=1.6)\naxes[1].set_xlabel(\"Required return (%)\"); axes[1].set_ylabel(\"Shadow price d(variance)/d(return)\")\naxes[1].set_title(\"Marginal risk cost of return (cuOpt QP dual)\"); axes[1].grid(alpha=0.3)\nplt.tight_layout(); plt.show()"
},
{
"cell_type": "markdown",
"id": "6888f83f",
"metadata": {},
"source": "## Reading it\n\n- The **frontier** (left) is the return-vs-risk Pareto set \u2014 every point is a min-variance portfolio for its return floor.\n- The **dual** (right) is the *exchange rate* the skill asks you to report: how much variance you take on per extra unit of return. It rises along the frontier \u2014 the marginal cost of return gets steeper, which is exactly where a knee analysis pays off.\n\n### Notes (honest)\n- **Synthetic data** \u2014 the base notebook's simulated universe; demonstrates the method.\n- **PDLP / first-order** \u2014 cuOpt's QP beta is a first-order solver, so the dual is optimal **to its convergence tolerance**, not exact arithmetic; points reported `PrimalFeasible` rather than `Optimal` are flagged above and could be tightened or dropped.\n- **Continuous only** \u2014 these duals exist because the portfolio is a QP. The integer workforce model (`workforce_optimization_multiobjective.ipynb`) has **no constraint duals**; there you read the marginal cost off the frontier itself.\n\nThis adds the duals/interpretation step of the `cuopt-multi-objective-exploration` skill to cuOpt's existing portfolio frontier."
},
{
"cell_type": "markdown",
"id": "b281399a",
"metadata": {},
"source": "## License\n\nSPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\nSPDX-License-Identifier: Apache-2.0\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\nhttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License."
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
7 changes: 6 additions & 1 deletion portfolio_optimization/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ The portfolio optimization notebook solves a portfolio optimization problem wher

- The aim is to balance expected return with the risk of losses

### 3. Advanced Portfolio Optimization
### 3. Portfolio Frontier with Shadow Prices (QP)

- Builds the efficient frontier as a named ε-constraint sweep (following the `cuopt-multi-objective-exploration` skill).
- Adds the return-constraint **dual** — the shadow price d(variance)/d(return) — along the frontier, with the PDLP-tolerance caveat.

### 4. Advanced Portfolio Optimization

For advanced portfolio optimization examples including:
- Efficient frontier construction
Expand Down
11 changes: 10 additions & 1 deletion workforce_optimization/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,13 @@ The workforce optimization notebook solves a mixed integer linear programming pr

- The goal is to assign workers to shifts while minimizing total labor cost.
- The workers have different availability and different pay rates.
- The shifts have different requirements.
- The shifts have different requirements.

### 2. Workforce Optimization (Multi-Objective)

Extends the MILP above into a Pareto frontier — choose the tradeoff instead of getting one plan:

- **cost vs. coverage** — sweep a coverage floor as an ε-constraint; read the marginal cost per shift off the frontier.
- **cost vs. fairness** — promote the fixed per-worker shift cap into a swept objective (a constraint treated as a candidate objective).

Follows the `cuopt-multi-objective-exploration` skill. A MILP has no constraint duals, so the marginal cost comes from the frontier itself.
Loading