3.4.1 Every portfolio is an optimisation problem
A portfolio is not picked; it is solved for. Name the three parts of the problem and most of the craft is already visible.
In Session 3.3 — Linear Algebra for Portfolios you met risk as a quadratic form: a portfolio with weight vector and covariance matrix has variance . Now turn the question around. Instead of "how risky is this portfolio?", ask "which portfolio is least risky, given the rules I must follow?" That question has three parts — and so does every optimisation problem in this course: mean-variance in Session 9.1, a cost-aware rebalance in Session 9.6, an execution schedule in Session 14.4, a fitted yield curve in Session 11.1.
The decision variables are what you choose: here the portfolio weights ( is the number of assets). The objective function is the single number you are scored on, to be minimised or maximised — here portfolio variance. A constraint is a rule every acceptable answer must obey: the weights sum to one (the budget), no weight is negative (long-only, so no short selling), no position exceeds a cap. The set of all weight vectors that obey every constraint is the feasible set, and the solution is its point with the best objective. Every solver speaks the same general form:
with the objective, the the inequality constraints and the the equality constraints. (In this session is any objective function, not the Kelly fraction of Session 3.6 — though maximising expected log growth there is itself a convex problem.) A maximisation is the minimisation of . The table translates rules that portfolio managers actually ask for into this language — and flags the ones that will cause trouble (§3.4.2 explains the last column).
| The rule, in words | In math | Convex? |
|---|---|---|
| Invest exactly the capital you have (budget) | ✓ a plane | |
| No short positions (long-only) | ✓ a half-space per asset | |
| No position above a cap | ✓ a half-space per asset | |
| Gross exposure at most (a leverage limit) | ✓ a norm below a constant | |
| Volatility at most | ✓ an ellipsoid | |
| No net exposure to the market | ✓ a plane | |
| Volatility at least | ✗ the outside of an ellipsoid | |
| At most names | ✗ a choice of subsets | |
| Hold zero or at least a minimum lot | or | ✗ two disconnected pieces |
A solver returns the best portfolio for the problem you stated, not the one you meant. Every surprise in portfolio optimisation — a giant position in one name, a "minimum-risk" book full of shorts, an exception at 6 a.m. — traces back to the statement of the problem. Most of the skill is in stating the problem; the solving is a library call.
cvxpy is a compiler. You write mathematics in a high-level language; it type-checks the program for convexity (the rules of §3.4.6 are its type system), lowers it to a standard form and hands it to a back end — a solver such as Clarabel, OSQP or SCS. Like a strict compiler, it rejects programs it cannot prove correct, including a few that are.
3.4.2 Convexity: one valley, no traps
Convexity is the property that turns optimisation from a search into a calculation. With it, any local minimum is the global one — and the solver can prove it.
A convex set contains the straight segment between any two of its members. A convex function has its chord — the segment between any two points on its graph — on or above the graph: a bowl, not a mountain range. For any two points and any between 0 and 1:
Why care? Three reasons, each of which saves real pain. Local is global: in a convex problem, a point that no small feasible move can improve is the best point, full stop — there are no side valleys to get stuck in. If you have trained neural networks you know the opposite world, where the loss is non-convex and the answer depends on the random seed; a convex portfolio problem has none of that. Reliable solvers: interior-point and splitting methods solve problems the size of this session's in milliseconds and ones with thousands of variables in seconds, and when they fail they say why (infeasible, unbounded; §3.4.7). Certificates: a convex solver returns multipliers with the answer, and they let anyone verify optimality without trusting the solver (§3.4.5).
Now the finance. Variance is convex in the weights because is positive semi-definite, and one line proves it. With :
The chord sits above the graph by exactly , which a covariance matrix never lets go negative. Linear rules — budget, long-only, caps, factor exposure — each carve out a flat-sided half-space or plane; an intersection of convex sets is convex, so any stack of them is fine. Norms are convex, so a gross-leverage limit is fine too.
The trouble-makers are rules with an "or" inside. "At most 10 names" is not convex: in the lab, one portfolio holds the first 10 names equally, another the last 10, and their 50/50 average holds 20. A cardinality rule is really a choice of which names — a search over subsets — so it needs different machinery: a mixed-integer solver, or a heuristic whose answer you cannot certify (the homework measures the gap).
For a convex problem, a solution that no small feasible move can improve is globally optimal, and the multipliers prove it. If you can state your problem convexly, you never have to wonder whether a better portfolio exists. If you can't, you are trading that guarantee for a heuristic — sometimes worth it, never free.
3.4.3 Quadratic programs & minimum variance
A convex quadratic objective with linear constraints is the workhorse of portfolio construction. Its simplest instance has a closed form, which gives you a ground truth to test the solver against.
A quadratic program (QP) minimises a convex quadratic function subject to linear constraints:
Minimum variance is the QP with , and one equality, the budget. With no other rule it has a closed form. Attach a multiplier to the budget, form the Lagrangian (§3.4.4 explains why this works; is not leverage), set its gradient to zero and solve:
The lab builds from 20 synthetic assets driven by three common factors — 5 years of daily returns, annualised, with individual volatilities from 24% to 38% and a condition number of 14 — and solves the problem both ways:
def min_var_closed_form(S: np.ndarray) -> tuple[np.ndarray, float]: """Minimum-variance weights under the budget only: w = S⁻¹1 / 1′S⁻¹1 (solve, never invert).""" x = np.linalg.solve(S, np.ones(len(S))) return x / x.sum(), 1.0 / x.sum() w_cf, var_cf = min_var_closed_form(S) w = cp.Variable(N_ASSETS) budget = cp.sum(w) == 1 prob = cp.Problem(cp.Minimize(cp.quad_form(w, S)), [budget]) prob.solve(solver=SOLVER)
The two answers differ by at most 4e-16 in any weight, and both give a volatility of 11.49%. Now look at the weights (fig 3.4.3, blue). The unconstrained minimum-variance portfolio shorts 6 of the assets, puts 26.3% into its favourite and runs gross leverage of 1.33. Many mandates forbid that, so add long-only, then a cap of 10% per name. Neither version has a closed form any more; for cvxpy each is one more line (w >= 0, w <= CAP).
| Rule set | Volatility | Names held | Held at zero | At the cap | Short positions |
|---|---|---|---|---|---|
| Budget only | 11.49% | 20 | — | — | 6 |
| + long-only | 11.88% | 13 | 7 | — | 0 |
| + long-only, cap 10% | 13.49% | 13 | 7 | 6 | 0 |
Two lessons sit in that table. Constraints can only cost you: each rule shrinks the feasible set, so the minimum can only rise — long-only costs 39 bp of volatility here, the cap another 161 bp. And many constraints don't matter: of the 20 floor constraints , only 7 hold with equality. A constraint that holds with equality at the optimum is binding; one satisfied with room to spare is slack, and the solution would not change if you deleted it. Which constraints bind is the most informative output of the whole exercise, and §3.4.4 puts a price on each.
The unconstrained weights are extreme partly because is estimated, and optimisers amplify estimation error — the error maximisation of Session 9.1. Jagannathan & Ma (2003) showed that imposing long-only on minimum variance is equivalent to using the sample covariance matrix after reducing its large elements — a form of shrinkage — which is why a constraint the true optimum would violate often helps out of sample. Session 9.2 estimates properly; 9.1 adds expected returns to the objective.
3.4.4 Lagrange multipliers: what a rule costs
Every constraint comes with a number that says how much the objective would improve if you loosened it a little. That number is a price, and after the answer itself it is the most useful thing a solver returns.
Picture the problem in two dimensions (fig 3.4.4). Variance contours are ellipses around the unconstrained minimum; a cap is a wall. If the unconstrained minimum lies on the forbidden side, the best you can do is slide down the bowl until you hit the wall, and stop where a contour just touches it. There, the downhill direction points straight into the wall — you would keep going if it let you. The wall pushes back, and the size of that push is the constraint's Lagrange multiplier.
Lagrange's device turns the picture into algebra. Price each constraint by adding it to the objective, multiplied by its multiplier; the result is the Lagrangian (this is unrelated to the eigenvalue of Session 3.3 — same letter, two different jobs):
For a convex problem that satisfies a mild condition — some point meets every inequality strictly (Slater's condition; it holds for the problems this session prices) — there are multipliers that make the constrained optimum a stationary point of . Those multipliers are shadow prices: they say how the optimal value moves when a constraint's limit moves (fig 3.4.5). For a constraint :
The second statement (Boyd & Vandenberghe §5.6.2) is the one to remember: loosening a rule by can never buy more than . A slack constraint has : moving a wall you never touch changes nothing.
Two prices, checked by finite differences
The lab checks the claim twice. First, the budget. If you must invest instead of 1, the minimum variance scales to , whose slope at is . The budget's multiplier is 0.026412, exactly twice the minimum variance of 0.013206, and re-solving at a budget a hair above and below 1 gives a slope of 0.026412. Stationarity, , says something more useful still: every asset's marginal variance — how fast portfolio variance grows as you add to asset — is the same, and equals . If one asset had a lower marginal variance than another, you would move a sliver of capital into it and lower the risk. Equal marginal risk is what "minimum" means; Session 9.3 builds risk contributions on the same derivative.
Second, a price a risk committee actually asks for. Allow shorts, but cap gross exposure: . The unconstrained portfolio runs 1.33, so a cap at 1.16 binds:
def min_var_gross(L: float, precise: bool = False): w = cp.Variable(N_ASSETS) gross = cp.norm1(w) <= L prob = cp.Problem(cp.Minimize(cp.quad_form(w, S)), [cp.sum(w) == 1, gross]) tight(prob) if precise else prob.solve(solver=SOLVER) return prob, gross, w p_L, gross_con, w_L = min_var_gross(L_CAP, precise=True) lam_L = float(gross_con.dual_value)
The multiplier is 0.002522 (variance per unit of gross leverage), and the finite difference agrees: 0.002522. Convert it to volatility with : loosening the cap by 0.02 should cut volatility by about 2.2 bp, and re-solving shows 2.0 bp. For a step of 0.10 the tangent promises 10.9 bp but delivers 7.0: the curve flattens as the cap stops binding (fig 3.4.6), exactly as the inequality above allows. At the cap forces , so there can be no shorts, and the problem becomes the long-only one of §3.4.3; the lab asserts the volatilities match.
cvxpy follows Boyd & Vandenberghe: for a constraint written expr <= c or expr == c, constraint.dual_value is the rate at which the optimal value falls as rises. Inequality duals are therefore never negative; an equality's can have either sign. The budget's is negative — raising the budget raises the variance — so the lab flips it (nu_budget = -budget.dual_value) to report . Get a sign wrong and you will tell the risk committee that loosening a limit costs money: check each new kind of constraint once with a finite difference.
answers the question every constraint deserves: what is this costing us? It is measured in units of the objective per unit of the limit, exact for small moves and an upper bound on the gain for large ones. A binding constraint with a large multiplier is a rule worth arguing about; a slack one is free.
3.4.5 KKT: a certificate you can check
The Karush–Kuhn–Tucker conditions turn "the solver says optimal" into four checks you can run yourself. For a convex problem, passing them proves optimality.
In words: the answer obeys every rule (primal feasibility); every inequality's price is non-negative (dual feasibility); a rule that does not bind has price zero, so a rule with a positive price must bind (complementary slackness); and no small move the rules allow can improve the objective, because its gradient is exactly balanced by the priced constraints (stationarity). Karush wrote them down in a 1939 master's thesis and Kuhn and Tucker published them in 1951, hence the KKT conditions. For a convex problem that satisfies Slater's condition they are necessary and sufficient: a point that passes all four is globally optimal.
For the long-only, capped minimum-variance portfolio, put on the budget, on each floor and on each cap , with a minus sign on the budget term so that comes out positive. Stationarity then reads, asset by asset:
nu = -c_box["budget"].dual_value # budget price (sign flipped to our convention) lam = c_box["floor"].dual_value # ≥ 0, non-zero only where w = 0 eta = c_box["cap"].dual_value # ≥ 0, non-zero only where w = cap marg_var = 2 * S @ w_box # ∂(w′Σw)/∂wᵢ: each asset's marginal variance kkt = { "primal": max(abs(w_box.sum() - 1), (-w_box).max(), (w_box - CAP).max(), 0.0), "dual": max(-lam.min(), -eta.min(), 0.0), "slack": max(np.abs(lam * w_box).max(), np.abs(eta * (CAP - w_box)).max()), "stationarity": np.abs(marg_var - nu - lam + eta).max(), }
| Condition | In words, for this portfolio | Formula | Lab residual |
|---|---|---|---|
| Primal feasibility | fully invested, nothing short, nothing above the cap | 2.2e-16 | |
| Dual feasibility | inequality prices are never negative | 0.0e+00 | |
| Complementary slackness | a floor or cap that doesn't bind has price zero | 4.9e-14 | |
| Stationarity | no trade within the rules lowers variance | 5.6e-15 |
Read the stationarity line asset by asset and it becomes finance. An asset held strictly between zero and the cap has , so its marginal variance equals the shared level — all 7 interior assets agree. An asset held at zero has marginal variance : even a small position in it would add risk faster than the rest of the book, and its multiplier says by exactly how much. An asset pinned at the cap has marginal variance : you would like more of it, and the cap is what stops you. Fig 3.4.7 draws the gap for every asset, relative to : from −87% for the most-wanted capped name to +38% for the least-wanted excluded one.
Checking KKT takes a dozen lines and catches what "status: optimal" cannot: a constraint with the wrong sign, a covariance matrix in the wrong units, a solver that stopped early. Run it on every new formulation, as you would a test suite, and keep it in the pipeline for the ones that go to production.
3.4.6 Stating it for cvxpy
cvxpy accepts a problem only if it can prove it convex from a small set of composition rules. Learn the rules and its error messages become a to-do list.
cvxpy is an open-source Python library for convex optimisation (Diamond & Boyd 2016): you declare variables, write the objective and constraints in near-mathematical syntax, and call solve(). Before solving, it checks the problem against the rules of Disciplined Convex Programming (DCP; Grant, Boyd & Ye 2006). Every function it knows — an atom — has a known curvature (convex, concave or affine) and a known monotonicity in each argument (increasing, decreasing or neither). An expression is certified convex if it is built by rules such as: a convex function of an affine expression is convex; a convex, increasing function of a convex expression is convex; a sum of convex expressions is convex. A problem passes if it minimises a convex expression (or maximises a concave one) subject to affine == affine, convex <= concave or concave >= convex. cvxpy then rewrites it into a solver's standard form (Agrawal et al. 2018) and calls the solver. Fig 3.4.8 is the workflow around that call.
The rules are sufficient, not necessary. The table is the lab's DCP gallery; each verdict is asserted by the lab, so if a cvxpy upgrade changes one, the lab fails rather than this page going stale.
| What you want | How it is written | DCP? | Why — and the fix |
|---|---|---|---|
| Minimise variance | cp.quad_form(w, S) | ✓ | convex quadratic; S is positive semi-definite |
| Minimise volatility | cp.sqrt(cp.quad_form(w, S)) | ✗ | sqrt is concave: no rule covers concave-of-convex, although the result is convex. Rewrite as a norm (below) |
| Minimise volatility | cp.norm(Lc.T @ w, 2) | ✓ | a norm of an affine expression; |
| Cap volatility | cp.quad_form(w, S) <= 0.12**2 | ✓ | convex below a constant |
| Floor volatility | cp.quad_form(w, S) >= 0.15**2 | ✗ | genuinely non-convex; no rewrite exists |
| Cap gross leverage | cp.norm1(w) <= 1.5 | ✓ | a norm below a constant |
| Fix gross leverage exactly | cp.norm1(w) == 1.5 | ✗ | equalities must be affine; use <= if the cap binds anyway |
| Penalise turnover | + theta * cp.norm1(w - w_prev) | ✓ | a convex penalty added to a convex objective |
| Maximise the Sharpe ratio | (mu @ w) / cp.norm(Lc.T @ w, 2) | ✗ | a ratio of expressions; a change of variables turns it into a QP (Session 9.1) |
The instructive row is volatility. is convex — it is a norm — but written as sqrt(quad_form(...)) it is a concave function of a convex expression, and cvxpy refuses with a DCPError. The fix is to write it as what it is. Every positive-definite covariance matrix has a Cholesky factorisation , with lower-triangular (np.linalg.cholesky), so and volatility is the norm , an atom cvxpy knows:
Lc = np.linalg.cholesky(S) # S = Lc Lc′, so w′Sw = ‖Lc′w‖² # rejected: raises cp.error.DCPError when solved cp.Problem(cp.Minimize(cp.sqrt(cp.quad_form(w, S))), [cp.sum(w) == 1]) # accepted: the same function, written as a norm cp.Problem(cp.Minimize(cp.norm(Lc.T @ w, 2)), [cp.sum(w) == 1])
It returns a volatility of 11.49%, the closed-form answer. The volatility floor is different in kind: the portfolios with at least 15% volatility form the outside of an ellipsoid, which is not convex however you write it. When cvxpy says no, ask which case you are in — a convex problem written badly (rewrite it) or a non-convex one (change the model). If what you really want is a portfolio at a target volatility, solve the convex problem and then scale the position: leverage multiplies volatility (Session 2.3), and Session 9.5 builds volatility targeting on exactly that.
A turnover-penalised rebalance (a preview of 9.6)
One more atom earns its keep every day: the L1 norm. A quarter ago you built the long-only, capped portfolio from last quarter's covariance matrix; today's estimate differs slightly (the two five-year windows share all but one quarter). Re-optimising from scratch trades 7 names, a turnover of 3.42%, to lower volatility by 0.55 bp — a sliver, and every trade pays transaction costs. So price the trading inside the objective with a turnover penalty of weight (not , which is Session 2.2's borrow fee):
def rebalance(theta: float): w = cp.Variable(N_ASSETS) obj = cp.quad_form(w, S) + theta * cp.norm1(w - w_prev) prob = cp.Problem(cp.Minimize(obj), [cp.sum(w) == 1, w >= 0, w <= CAP]) prob.solve(solver=cp.OSQP, **OSQP_TIGHT) # tight, so "traded" means traded, not solver noise
| Penalty θ | Turnover | Names traded | Volatility |
|---|---|---|---|
| 0 | 3.42% | 7 | 13.489% |
| 0.0001 | 2.89% | 7 | 13.489% |
| 0.0003 | 1.84% | 7 | 13.490% |
| 0.001 | 0.36% | 3 | 13.493% |
| 0.003 | 0.00% | 0 | 13.495% |
The absolute value has a kink at zero, and KKT explains what the kink does. At a kink the "gradient" may be any slope between and , so a name is left untouched whenever the marginal variance it would save per unit traded is smaller than . That is a no-trade band: small penalties shrink every trade, and larger ones switch trades off entirely instead of shaving them. Session 9.6 calibrates the penalty to real costs, and Session 14.4 solves a related quadratic trade-off, between market impact and risk, for the schedule of a single large order (the Almgren–Chriss model).
3.4.7 When the solver says no
A solver can fail loudly, with a status that isn't "optimal", or quietly, with an "optimal" answer that is only approximately right. Handle the first with the status code and the second with tolerance-aware checks.
| Status | What it means | prob.value (minimising) | What to do |
|---|---|---|---|
optimal | solved to the solver's tolerance | a number | verify anyway (§3.4.5) |
optimal_inaccurate | stopped early; close, not certified | a number | rescale, tighten or switch solver before using it |
infeasible | no weights satisfy every constraint | the rules contradict each other: drop them one at a time to find the conflict | |
unbounded | the objective improves without limit | a rule or a risk term is missing | |
infeasible_inaccurate, unbounded_inaccurate | probably, but not certified | as above | as above, after rescaling |
For a maximisation the infinities swap sign (cvxpy documentation). The lab provokes both loud failures. Cap every asset at 4% and all the caps together can hold only 80% of the budget: the status is infeasible and w.value is None, so code that reads the weights without checking the status crashes — or, worse, reuses stale ones. Maximise forecast return with only a budget constraint and the status is unbounded: short the worst forecast, buy the best, and repeat forever. Neither is the solver's fault. Infeasible means your rules contradict each other; unbounded means a rule is missing — here a risk term or a leverage limit. Adding a gross cap of 1.5 turns it into a small linear program with status optimal.
The quiet failure is tolerance. Solvers stop when their residuals fall below a threshold — by default about for Clarabel (an interior-point method; Goulart & Chen 2024), and for OSQP and for SCS (splitting methods; Stellato et al. 2020, O'Donoghue et al. 2016) as cvxpy configures them. So "zero" comes back as a speck of dust on either side of zero, and a budget sums to a hair under one. The lab solves the long-only, capped problem with each solver at its default settings and compares with a reference solved at far tighter tolerance:
| Solver | Max weight error, annual units | Volatility error (bp) | Smallest weight (should be ≥ 0) | Max weight error, daily units |
|---|---|---|---|---|
| Clarabel | 1.9e-04 | 2.1e-04 | 6.4e-09 | 1.9e-03 |
| OSQP | 9.3e-09 | −2.3e-09 | −1.1e-23 | 9.3e-09 |
| SCS | 3.3e-08 | −5.8e-05 | −5.4e-09 | 9.5e-09 |
Every one of those runs reported optimal. Three lessons follow. Weights can be far less accurate than the objective: default Clarabel leaves asset 14 at about 1.9e-04 when it should hold nothing, yet the volatility is right to a tiny fraction of a basis point. That asset's multiplier is almost zero (5.2e-06), so the objective is nearly flat along it and stopping short costs almost nothing. Units matter: the same problem in daily units, every number 252 times smaller, comes back 10 times less accurate from Clarabel; solvers are tuned for numbers of order one. (The exact multiplier depends on the solver version — the lab only asserts it is above 3× — so don't be surprised to measure 6× or 20× on a different install.) Solvers differ: OSQP's polishing step — it guesses which constraints bind and solves that smaller system exactly (Stellato et al. 2020) — lands within 9e-09 of the reference in both units here. And "zero" has a side: Clarabel's smallest weight came back slightly positive, OSQP's and SCS's a hair below zero — for a long-only portfolio.
It means the residuals fell below a tolerance. Never test w == 0 or w >= 0 exactly; compare against a threshold (the lab uses ), then clean the output — clip dust to zero, renormalise, re-check every constraint — before it becomes an order. Scale the problem so its numbers are of order one (annualised variances, weights rather than dollars). Cross-check an important solve with a second solver, and treat optimal_inaccurate as a failure until you understand it.
3.4.8 Try it
Open the companion lab, predict before you run, and change one thing at a time.
1. Tighten the cap. Change CAP from 0.10 to 0.08 and rerun. Before you look: will more or fewer names sit at the cap, and will the volatility in §3.4.3's table rise or fall? Then check which bars in fig 3.4.7 changed colour.
2. Price a tighter leverage limit. Set L_CAP = 1.05 by hand in section 4. Will be larger or smaller than at 1.16? Use the shape of the curve in fig 3.4.6 to decide, then check the multiplier against its finite difference.
3. Break DCP on purpose. In a new cell, try to target volatility exactly with the constraint cp.quad_form(w, S) == 0.12**2, solve, and read the error. Then write the convex version of the rule you probably meant.
Success looks like this: your predictions in steps 1 and 2 have the right sign, and you can say in one sentence why step 3 is rejected — and why "at most 12%" is not.
3.4.9 Glossary · Check yourself · Go deeper
Check yourself
1. A portfolio manager asks for "the minimum-variance portfolio with at most 15 names". Why can't you just add one more constraint in cvxpy, and what can you do instead?
2. At a gross-leverage cap of 1.16, the cap's multiplier is 0.0025 (variance per unit of leverage) and volatility is 11.57%. The risk committee offers 0.02 more. Roughly how much volatility does that buy?
3. cvxpy reports optimal for a long-only portfolio, yet one weight is −5.4e-09 (from SCS) and, in a run with Clarabel, a name that should hold nothing holds 1.9e-04. Is it a bug?
4. Your "maximise expected return" optimisation returns unbounded. What did you most likely forget?
5. Volatility is a convex function of the weights. So why does cvxpy reject cp.Minimize(cp.sqrt(cp.quad_form(w, S)))?
cp.norm(Lc.T @ w, 2) with , and it is accepted — or minimise the variance, which has the same minimiser.6. In the long-only, capped solution, asset A holds zero with a positive floor multiplier, and asset B holds 5% with both multipliers zero. What do you know about their marginal variances?
1. Price market neutrality. In a new cell after section 4, take beta = market["loadings"].to_numpy()[order, 0] (each asset's exposure to the first factor, re-ordered like the lab's assets) and minimise variance subject to the budget, cp.norm1(w) <= 3 and beta @ w == 0. How much volatility does neutrality cost? Check the neutrality constraint's multiplier against a finite difference on the right-hand side — remember cvxpy's sign convention. Success: they agree to at least five decimal places.
2. Cardinality, honestly. Take the long-only solution, keep its 8 largest names and re-solve on those alone. Then brute-force every eight-name subset of the names it holds (a few thousand small QPs at most — seconds). Is the "keep the largest" heuristic the best 8-name portfolio? Report the volatility gap.
3. Read Boyd & Vandenberghe §4.4 (quadratic programs) and §5.5–5.6 (KKT and sensitivity), and the cvxpy DCP tutorial. Then write five expressions of your own and predict is_dcp() before running it.
📚 Go deeper
- Boyd, S. & Vandenberghe, L. (2004). Convex Optimization, ch. 2–5 (convex sets, convex functions, problems, duality). Cambridge University Press; free PDF from the authors. ISBN 978-0-521-83378-3 · https://web.stanford.edu/~boyd/cvxbook/
- Diamond, S. & Boyd, S. (2016). "CVXPY: A Python-Embedded Modeling Language for Convex Optimization." Journal of Machine Learning Research 17(83), 1–5. arXiv:1603.00943
- Grant, M., Boyd, S. & Ye, Y. (2006). "Disciplined Convex Programming." In L. Liberti & N. Maculan (eds.), Global Optimization: From Theory to Implementation, 155–210. Springer. doi:10.1007/0-387-30528-9_7
- Cornuéjols, G., Peña, J. & Tütüncü, R. (2018). Optimization Methods in Finance, 2nd ed. Cambridge University Press. ISBN 978-1-107-05674-9
- Boyd, S., Busseti, E., Diamond, S., Kahn, R. N., Koh, K., Nystrup, P. & Speth, J. (2017). "Multi-Period Trading via Convex Optimization." Foundations and Trends in Optimization 3(1), 1–76. doi:10.1561/2400000023
Sources for the numbers
- Kuhn, H. W. & Tucker, A. W. (1951). "Nonlinear Programming." Proceedings of the Second Berkeley Symposium on Mathematical Statistics and Probability, 481–492. University of California Press. (Karush's 1939 University of Chicago master's thesis stated the conditions first.) https://projecteuclid.org/ebooks/berkeley-symposium-on-mathematical-statistics-and-probability/Proceedings-of-the-Second-Berkeley-Symposium-on-Mathematical-Statistics-and/chapter/Nonlinear-Programming/bsmsp/1200500249
- Jagannathan, R. & Ma, T. (2003). "Risk Reduction in Large Portfolios: Why Imposing the Wrong Constraints Helps." Journal of Finance 58(4), 1651–1684. doi:10.1111/1540-6261.00580
- Agrawal, A., Verschueren, R., Diamond, S. & Boyd, S. (2018). "A Rewriting System for Convex Optimization Problems." Journal of Control and Decision 5(1), 42–60. doi:10.1080/23307706.2017.1397554
- Goulart, P. J. & Chen, Y. (2024). "Clarabel: An Interior-Point Solver for Conic Programs with Quadratic Objectives." arXiv:2405.12762
- Stellato, B., Banjac, G., Goulart, P., Bemporad, A. & Boyd, S. (2020). "OSQP: An Operator Splitting Solver for Quadratic Programs." Mathematical Programming Computation 12, 637–672. doi:10.1007/s12532-020-00179-2
- O'Donoghue, B., Chu, E., Parikh, N. & Boyd, S. (2016). "Conic Optimization via Operator Splitting and Homogeneous Self-Dual Embedding." Journal of Optimization Theory and Applications 169(3), 1042–1068. doi:10.1007/s10957-016-0892-3
- cvxpy documentation (checked September 2026): DCP rules, problem status values, solver options and default tolerances; Clarabel documentation for its default tolerances. https://www.cvxpy.org/tutorial/dcp/index.html · https://www.cvxpy.org/tutorial/intro/index.html · https://www.cvxpy.org/tutorial/solvers/index.html · https://clarabel.org/stable/api_settings/
Educational material only — not investment, legal or tax advice. Backtests and simulations are hypothetical. Disclaimer · Text CC BY 4.0 · Code MIT.