Unit 3 · The Quant Math Toolkit — Session 04 of 06

Convex Optimization

Every portfolio you will build is the answer to an optimisation problem: make risk as small as possible, subject to rules. State it as a convex problem and a solver returns the best answer in milliseconds, proves that nothing better exists, and tells you what each rule is costing you. State it carelessly and you get an error message — or a confident, slightly wrong answer. The core move: write the problem so its convexity is visible, then read the multipliers as the price of each constraint.

DURATION 1.5 h + lab LEVEL Foundations TRACKS QR · QT · QD · IND HANDS-ON Minimum variance four ways; price a leverage cap

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 ww and covariance matrix Σ\Sigma has variance w⊤Σww^\top \Sigma w. 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.

ANATOMY OF AN OPTIMISATION PROBLEM — the minimum-variance portfolio DECISION VARIABLES w = the N portfolio weights what you get to choose OBJECTIVE FUNCTION minimise w′Σw, the variance the one number you are judged on CONSTRAINTS budget 1′w = 1 · long-only · caps rules every answer must obey SOLVER Clarabel · OSQP · SCS called by cvxpy WHAT COMES BACK w* — the best weights the winning point of the feasible set status optimal, infeasible, unbounded … optimal value p* the lowest achievable variance multipliers λ, ν the price of each constraint You write the three boxes on the left. The solver returns the answer — and a price for every rule.
fig 3.4.1 — the anatomy of an optimisation problem: you choose the variables, the objective and the constraints; the solver returns the best point, a status, the optimal value and one price per constraint

The decision variables are what you choose: here the NN portfolio weights (NN 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:

minw⁡f(w)subject togi(w)≤0,i=1,…,m,hj(w)=0,j=1,…,p\min_{w}\; f(w) \quad \text{subject to} \quad g_i(w) \le 0,\;\; i = 1,\dots,m, \qquad h_j(w) = 0,\;\; j = 1,\dots,p

with ff the objective, the gig_i the inequality constraints and the hjh_j the equality constraints. (In this session ff is any objective function, not the Kelly fraction ff of Session 3.6 — though maximising expected log growth there is itself a convex problem.) A maximisation is the minimisation of −f-f. 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 wordsIn mathConvex?
Invest exactly the capital you have (budget)𝟏⊤w=1\mathbf 1^\top w = 1✓ a plane
No short positions (long-only)wi≥0w_i \ge 0✓ a half-space per asset
No position above a cap uuwi≤uw_i \le u✓ a half-space per asset
Gross exposure at most LL (a leverage limit)‖w‖1=∑i|wi|≤L\lVert w \rVert_1 = \sum_i \lvert w_i \rvert \le L✓ a norm below a constant
Volatility at most σmax⁡\sigma_{\max}w⊤Σw≤σmax⁡2w^\top \Sigma w \le \sigma_{\max}^2✓ an ellipsoid
No net exposure to the marketβ⊤w=0\beta^\top w = 0✓ a plane
Volatility at least σmin⁡\sigma_{\min}w⊤Σw≥σmin⁡2w^\top \Sigma w \ge \sigma_{\min}^2✗ the outside of an ellipsoid
At most KK names#{i:wi≠0}≤K\#\{i : w_i \ne 0\} \le K✗ a choice of subsets
Hold zero or at least a minimum lot mmwi=0w_i = 0 or wi≥mw_i \ge m✗ two disconnected pieces
📌 Key idea — the solver answers the question you wrote

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.

🧭 Margin note — an engineer's analogy

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 x,yx, y and any tt between 0 and 1:

x,y∈C⇒tx+(1−t)y∈C,f(tx+(1−t)y)≤tf(x)+(1−t)f(y)x, y \in C \;\Rightarrow\; t x + (1-t) y \in C, \qquad\qquad f\big(t x + (1-t) y\big) \;\le\; t\, f(x) + (1-t)\, f(y)
ONE VALLEY OR MANY — why convexity is the property that matters CONVEX one bowl, one bottom chord stays above the curve global minimum — the only one CONVEX SET the segment between any two members stays inside e.g. long-only + caps NON-CONVEX many valleys, many bottoms curve rises above the chord local minimum: a solver can stop here global minimum the real answer NON-CONVEX SET a segment can leave it e.g. at most K names: average two, hold up to 2K In a bowl every downhill path reaches the same bottom; in a range, where you stop depends on where you start.
fig 3.4.2 — convex (left) versus non-convex (right): for a convex function every chord lies above the graph and there is one bottom; for a convex set every segment between members stays inside

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 Σ\Sigma is positive semi-definite, and one line proves it. With d=x−yd = x - y:

tf(x)+(1−t)f(y)−f(tx+(1−t)y)=t(1−t)d⊤Σd≥0for f(w)=w⊤Σwt\, f(x) + (1-t)\, f(y) - f\big(t x + (1-t) y\big) \;=\; t(1-t)\; d^\top \Sigma\, d \;\ge\; 0 \qquad \text{for } f(w) = w^\top \Sigma w

The chord sits above the graph by exactly t(1−t)d⊤Σdt(1-t)\,d^\top\Sigma d, 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).

📌 Key idea — convex means the answer is the answer

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:

minw⁡12w⊤Pw+q⊤wsubject toGw≤h,Aw=b,P positive semi-definite\min_{w}\; \tfrac12\, w^\top P\, w + q^\top w \quad \text{subject to} \quad G w \le h, \quad A w = b, \qquad P \text{ positive semi-definite}

Minimum variance is the QP with P=2ΣP = 2\Sigma, q=0q = 0 and one equality, the budget. With no other rule it has a closed form. Attach a multiplier ν\nu to the budget, form the Lagrangian ℒ\mathcal L (§3.4.4 explains why this works; ℒ\mathcal L is not leverage), set its gradient to zero and solve:

ℒ(w,ν)=w⊤Σw−ν(𝟏⊤w−1),2Σw⋆=ν𝟏⇒w⋆=Σ−1𝟏𝟏⊤Σ−1𝟏,σmin⁡2=1𝟏⊤Σ−1𝟏\mathcal L(w, \nu) = w^\top \Sigma w - \nu\,(\mathbf 1^\top w - 1), \qquad 2\Sigma w^\star = \nu\,\mathbf 1 \;\;\Rightarrow\;\; w^\star = \frac{\Sigma^{-1}\mathbf 1}{\mathbf 1^\top \Sigma^{-1} \mathbf 1}, \qquad \sigma^2_{\min} = \frac{1}{\mathbf 1^\top \Sigma^{-1}\mathbf 1}

The lab builds Σ\Sigma 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).

MINIMUM-VARIANCE WEIGHTS UNDER THREE RULE SETS (ASSETS 1–20) no limits long-only long-only, cap 10% −20% −10% 0% 10% 20% 30% 40% 1 · no limits: 26% 1 · long-only: 28% 1 · long-only, cap 10%: 10% 1 2 · no limits: 17% 2 · long-only: 17% 2 · long-only, cap 10%: 10% 2 3 · no limits: 12% 3 · long-only: 9% 3 · long-only, cap 10%: 10% 3 4 · no limits: 10% 4 · long-only: 10% 4 · long-only, cap 10%: 10% 4 5 · no limits: 8% 5 · long-only: 8% 5 · long-only, cap 10%: 10% 5 6 · no limits: 7% 6 · long-only: 4% 6 · long-only, cap 10%: 8% 6 7 · no limits: 7% 7 · long-only: 6% 7 · long-only, cap 10%: 10% 7 8 · no limits: 6% 8 · long-only: 4% 8 · long-only, cap 10%: 7% 8 9 · no limits: 5% 9 · long-only: 3% 9 · long-only, cap 10%: 4% 9 10 · no limits: 5% 10 · long-only: 4% 10 · long-only, cap 10%: 7% 10 11 · no limits: 5% 11 · long-only: 3% 11 · long-only, cap 10%: 5% 11 12 · no limits: 5% 12 · long-only: 2% 12 · long-only, cap 10%: 6% 12 13 · no limits: 2% 13 · long-only: 1% 13 · long-only, cap 10%: 3% 13 14 · no limits: 1% 14 · long-only: 0% 14 · long-only, cap 10%: 0% 14 15 · no limits: 0% 15 · long-only: 0% 15 · long-only, cap 10%: 0% 15 16 · no limits: −1% 16 · long-only: 0% 16 · long-only, cap 10%: 0% 16 17 · no limits: −1% 17 · long-only: 0% 17 · long-only, cap 10%: 0% 17 18 · no limits: −2% 18 · long-only: 0% 18 · long-only, cap 10%: 0% 18 19 · no limits: −6% 19 · long-only: 0% 19 · long-only, cap 10%: 0% 19 20 · no limits: −6% 20 · long-only: 0% 20 · long-only, cap 10%: 0% 20
fig 3.4.3 — minimum-variance weights for the same assets under three rule sets; assets are numbered by their unconstrained weight, so the shorts sit on the right and the capped names on the left
Rule setVolatilityNames heldHeld at zeroAt the capShort positions
Budget only11.49%20——6
+ long-only11.88%137—0
+ long-only, cap 10%13.49%13760

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 wi≥0w_i \ge 0, 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.

💡 Looking ahead — why "wrong" constraints help

The unconstrained weights are extreme partly because Σ\Sigma 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 Σ\Sigma 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.

WHERE THE OPTIMUM SITS WHEN A RULE BINDS — two weights, one wall weight on asset B ↑ weight on asset A → binding wall: weight on A ≤ cap × U w* best feasible point −∇f floor: weight on B ≥ 0 — slack, λ = 0 feasible set FEASIBLE SET (shaded) every w that obeys all the rules; convex here — a box BINDING CONSTRAINT the wall the optimum rests on: it holds with equality and λ > 0 AT w*: −∇f = λ ∇g downhill points straight into the wall; λ is how hard it pushes back × U = unconstrained minimum (forbidden) The optimum slides down the variance bowl until a wall stops it; the wall's push is the multiplier.
fig 3.4.4 — a binding constraint in two dimensions: the best feasible point is where a variance contour touches the wall, and there the downhill direction is perpendicular to the wall

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 λ\lambda is unrelated to the eigenvalue λ\lambda of Session 3.3 — same letter, two different jobs):

ℒ(w,λ,ν)=f(w)+∑iλigi(w)+∑jνjhj(w),λi≥0\mathcal L(w, \lambda, \nu) \;=\; f(w) + \sum_i \lambda_i\, g_i(w) + \sum_j \nu_j\, h_j(w), \qquad \lambda_i \ge 0

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 ℒ\mathcal L. Those multipliers are shadow prices: they say how the optimal value p⋆p^\star moves when a constraint's limit moves (fig 3.4.5). For a constraint g(w)≤cg(w) \le c:

∂p⋆∂c=−λ(where p⋆ is smooth),p⋆(c+δ)≥p⋆(c)−λδ(for every δ)\frac{\partial p^\star}{\partial c} \;=\; -\lambda \quad \text{(where } p^\star \text{ is smooth)}, \qquad\qquad p^\star(c + \delta) \;\ge\; p^\star(c) - \lambda\,\delta \quad \text{(for every } \delta\text{)}

The second statement (Boyd & Vandenberghe §5.6.2) is the one to remember: loosening a rule by δ\delta can never buy more than λδ\lambda\delta. A slack constraint has λ=0\lambda = 0: moving a wall you never touch changes nothing.

WHAT A MULTIPLIER MEASURES — move the wall, watch the best value move IN THE WEIGHTS PLANE move the wall by δ × wall at c: best value p*(c) wall at c + δ: p*(c + δ) ≈ p*(c) − λδ BEST VALUE AS THE WALL MOVES best value p* c c + δ tangent: slope −λ wall no longer binds: λ = 0 the rule's limit c (e.g. a cap) → λ is the slope of the best value as the wall moves — exact for small moves, optimistic for large ones.
fig 3.4.5 — the shadow price: moving a binding wall by δ lets the optimum reach a lower contour (left); the best value, as a function of the wall's position, has slope −λ and stays above its tangent (right)

Two prices, checked by finite differences

The lab checks the claim twice. First, the budget. If you must invest bb instead of 1, the minimum variance scales to b2σmin⁡2b^2 \sigma^2_{\min}, whose slope at b=1b = 1 is 2σmin⁡22\sigma^2_{\min}. The budget's multiplier is ν=\nu = 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, 2Σw⋆=ν𝟏2\Sigma w^\star = \nu\mathbf 1, says something more useful still: every asset's marginal variance 2(Σw)i2(\Sigma w)_i — how fast portfolio variance grows as you add to asset ii — is the same, and equals ν\nu. 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: ‖w‖1≤L\lVert w \rVert_1 \le L. The unconstrained portfolio runs 1.33, so a cap at L=L = 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 λL=\lambda_L = 0.002522 (variance per unit of gross leverage), and the finite difference agrees: 0.002522. Convert it to volatility with Δσ≈Δ(σ2)/(2σ)\Delta\sigma \approx \Delta(\sigma^2)/(2\sigma): 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 L=1L = 1 the cap forces ∑i|wi|=∑iwi\sum_i \lvert w_i\rvert = \sum_i w_i, so there can be no shorts, and the problem becomes the long-only one of §3.4.3; the lab asserts the volatilities match.

LOWEST VOLATILITY VS THE GROSS-LEVERAGE CAP L lowest achievable volatility tangent from the multiplier 11.4% 11.5% 11.6% 11.7% 11.8% 11.9% 1 1.1 1.2 1.3 1.4 lowest achievable volatility lowest achievable volatility: 11.5% tangent from the multiplier lowest vol: 11.5%
fig 3.4.6 — the lowest achievable volatility as the gross-leverage cap L loosens, with the tangent implied by the multiplier at the lab's cap: accurate nearby, optimistic further out, and flat once the cap no longer binds
🧭 Margin note — cvxpy's sign convention

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 cc 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 ν=2σmin⁡2>0\nu = 2\sigma^2_{\min} \gt 0. 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.

📌 Key idea — a multiplier is the price of a rule

λ\lambda 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.

gi(w⋆)≤0,hj(w⋆)=0;λi≥0;λigi(w⋆)=0;∇f(w⋆)+∑iλi∇gi(w⋆)+∑jνj∇hj(w⋆)=0g_i(w^\star) \le 0,\;\; h_j(w^\star) = 0; \qquad \lambda_i \ge 0; \qquad \lambda_i\, g_i(w^\star) = 0; \qquad \nabla f(w^\star) + \sum_i \lambda_i \nabla g_i(w^\star) + \sum_j \nu_j \nabla h_j(w^\star) = 0

For the long-only, capped minimum-variance portfolio, put ν\nu on the budget, λi\lambda_i on each floor wi≥0w_i \ge 0 and ηi\eta_i on each cap wi≤uw_i \le u, with a minus sign on the budget term so that ν\nu comes out positive. Stationarity then reads, asset by asset:

ℒ=w⊤Σw−ν(𝟏⊤w−1)−λ⊤w+η⊤(w−u𝟏)⇒2(Σw)i=ν+λi−ηi\mathcal L = w^\top \Sigma w - \nu\,(\mathbf 1^\top w - 1) - \lambda^\top w + \eta^\top (w - u\mathbf 1) \quad\Rightarrow\quad 2(\Sigma w)_i \;=\; \nu + \lambda_i - \eta_i
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(),
}
ConditionIn words, for this portfolioFormulaLab residual
Primal feasibilityfully invested, nothing short, nothing above the cap𝟏⊤w=1,0≤w≤u\mathbf 1^\top w = 1,\; 0 \le w \le u2.2e-16
Dual feasibilityinequality prices are never negativeλ≥0,η≥0\lambda \ge 0,\; \eta \ge 00.0e+00
Complementary slacknessa floor or cap that doesn't bind has price zeroλiwi=0,ηi(u−wi)=0\lambda_i w_i = 0,\; \eta_i (u - w_i) = 04.9e-14
Stationarityno trade within the rules lowers variance2Σw=ν𝟏+λ−η2\Sigma w = \nu \mathbf 1 + \lambda - \eta5.6e-15

Read the stationarity line asset by asset and it becomes finance. An asset held strictly between zero and the cap has λi=ηi=0\lambda_i = \eta_i = 0, so its marginal variance equals the shared level ν\nu — all 7 interior assets agree. An asset held at zero has marginal variance ν+λi\nu + \lambda_i: 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 ν−ηi\nu - \eta_i: 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 ν\nu: from −87% for the most-wanted capped name to +38% for the least-wanted excluded one.

EACH ASSET'S MARGINAL VARIANCE VS THE BUDGET PRICE (LONG-ONLY, CAPPED) −150% −100% −50% 0% 50% 100% 1: −87% 1 2: −48% 2 3: −15% 3 4: −14% 4 5: −13% 5 6: 0% 6 7: −4% 7 8: 0% 8 9: 0% 9 10: 0% 10 11: 0% 11 12: 0% 12 13: 0% 13 14: 0% 14 15: 9% 15 16: 7% 16 17: 14% 17 18: 19% 18 19: 34% 19 20: 38% 20
fig 3.4.7 — each asset's marginal variance relative to the budget price ν in the long-only, capped portfolio: green bars are assets at the cap (below ν by their η), orange bars are assets held at zero (above ν by their λ), and the interior assets sit exactly at zero
💡 Tip — treat KKT residuals as a unit test

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 OPTIMISATION WORKFLOW — from a wish to an answer you can trust 1 · STATE variables, objective, constraints — in math 2 · CHECK convex? is_dcp() rewrite until it is 3 · SOLVE read prob.status before w.value 4 · VERIFY KKT residuals, budget, bounds, signs 5 · STRESS-TEST new data, second solver, tighter tolerance DCP ERROR convex but badly written? or truly non-convex? BAD STATUS infeasible, unbounded: the model is wrong BIG RESIDUALS rescale, tighten, or switch solver TRUSTED ANSWER weights, value and prices you can defend back to step 1 — most bugs live in the statement of the problem Steps 2–5 are cheap. Skipping them is how a wrong portfolio reaches the market.
fig 3.4.8 — the optimisation workflow: state the problem, check convexity (DCP), solve and read the status, verify with KKT and sanity checks, stress-test; every failure sends you back to the statement

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 wantHow it is writtenDCP?Why — and the fix
Minimise variancecp.quad_form(w, S)✓convex quadratic; S is positive semi-definite
Minimise volatilitycp.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 volatilitycp.norm(Lc.T @ w, 2)✓a norm of an affine expression; Σ=LcLc⊤\Sigma = L_c L_c^\top
Cap volatilitycp.quad_form(w, S) <= 0.12**2✓convex below a constant
Floor volatilitycp.quad_form(w, S) >= 0.15**2✗genuinely non-convex; no rewrite exists
Cap gross leveragecp.norm1(w) <= 1.5✓a norm below a constant
Fix gross leverage exactlycp.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. w⊤Σw\sqrt{w^\top\Sigma w} 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 Σ=LcLc⊤\Sigma = L_c L_c^\top, with LcL_c lower-triangular (np.linalg.cholesky), so w⊤Σw=‖Lc⊤w‖2w^\top \Sigma w = \lVert L_c^\top w \rVert^2 and volatility is the norm ‖Lc⊤w‖2\lVert L_c^\top w\rVert_2, 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 θ\theta (not κ\kappa, which is Session 2.2's borrow fee):

minw⁡w⊤Σw+θ‖w−wprev‖1subject to𝟏⊤w=1,0≤w≤u\min_{w}\; w^\top\Sigma w + \theta\, \lVert w - w_{\text{prev}} \rVert_1 \quad \text{subject to} \quad \mathbf 1^\top w = 1,\;\; 0 \le w \le u
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 θTurnoverNames tradedVolatility
03.42%713.489%
0.00012.89%713.489%
0.00031.84%713.490%
0.0010.36%313.493%
0.0030.00%013.495%
TURNOVER OF THE REBALANCE AS THE PENALTY θ GROWS 0.0% 1.0% 2.0% 3.0% 4.0% 0: 3.4% 3.4% 0 0.0001: 2.9% 2.9% 0.0001 0.0003: 1.8% 1.8% 0.0003 0.001: 0.4% 0.4% 0.001 0.003: 0.0% 0.0% 0.003 0.01: 0.0% 0.0% 0.01
fig 3.4.9 — turnover of the rebalance as the penalty θ grows: small penalties shrink every trade, larger ones switch trades off entirely, and past a threshold the old portfolio is kept

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 −θ-\theta and +θ+\theta, so a name is left untouched whenever the marginal variance it would save per unit traded is smaller than θ\theta. 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.

StatusWhat it meansprob.value (minimising)What to do
optimalsolved to the solver's tolerancea numberverify anyway (§3.4.5)
optimal_inaccuratestopped early; close, not certifieda numberrescale, tighten or switch solver before using it
infeasibleno weights satisfy every constraint+∞+\inftythe rules contradict each other: drop them one at a time to find the conflict
unboundedthe objective improves without limit−∞-\inftya rule or a risk term is missing
infeasible_inaccurate, unbounded_inaccurateprobably, but not certifiedas aboveas 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 10−810^{-8} for Clarabel (an interior-point method; Goulart & Chen 2024), and 10−510^{-5} for OSQP and 10−410^{-4} 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:

SolverMax weight error, annual unitsVolatility error (bp)Smallest weight (should be ≥ 0)Max weight error, daily units
Clarabel1.9e-042.1e-046.4e-091.9e-03
OSQP9.3e-09−2.3e-09−1.1e-239.3e-09
SCS3.3e-08−5.8e-05−5.4e-099.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.

⚠️ Pitfall — "status optimal means the weights are exact"

It means the residuals fell below a tolerance. Never test w == 0 or w >= 0 exactly; compare against a threshold (the lab uses 10−610^{-6}), 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.

🔧 Try it (15 min, in the lab)

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 λL\lambda_L 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

Objective function — the single number an optimisation minimises or maximises; for minimum variance, w⊤Σww^\top\Sigma w.
Constraint — a rule every acceptable solution must satisfy, written as an equality or inequality in the decision variables.
Decision variable — what an optimisation problem chooses; here, the portfolio weight vector ww.
Feasible set — every choice of decision variables that satisfies every constraint; the solution is its best point.
Convex set — a set that contains the straight segment between any two of its members.
Convex function — a function whose chord between any two points lies on or above its graph; every local minimum is global.
Quadratic program — minimising a convex quadratic objective subject to linear equality and inequality constraints.
Binding constraint — a constraint that holds with equality at the optimum, as opposed to a slack one with room to spare.
Lagrange multiplier — the price attached to a constraint: how fast the optimal value improves as the constraint is loosened.
Shadow price — a Lagrange multiplier read as a price: how much the optimal value would move if a constraint's limit moved.
Marginal variance — how fast portfolio variance grows as capital is added to one asset, 2(Σw)i2(\Sigma w)_i.
KKT conditions — primal feasibility, dual feasibility, complementary slackness and stationarity; for a convex problem, a certificate of optimality.
Disciplined Convex Programming — the composition rules cvxpy uses to certify an expression convex before it will solve the problem.
cvxpy — an open-source Python library that checks a problem against the DCP rules, rewrites it into a solver's standard form and calls the solver.

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?
"At most 15 names" is not a convex set: average two 15-name portfolios with different names and you can hold 30. It is a choice of subsets, so it needs boolean variables and a mixed-integer solver, or a heuristic — solve without the rule, keep the largest names, re-solve on them. The heuristic is fast but carries no certificate; the homework shows it can miss the best subset.
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?
The variance falls by about λδ\lambda\delta, and Δσ≈λδ/(2σ)≈\Delta\sigma \approx \lambda\delta/(2\sigma) \approx 2.2 bp; re-solving gives 2.0 bp. For large steps the multiplier overstates the gain — it is an upper bound, because the best value stays above its tangent — so re-solve before promising anything big.
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?
No — it is tolerance. Solvers stop when residuals fall below a threshold, and where the objective is nearly flat (an asset with a tiny multiplier) the weights can sit well away from the exact answer at almost no cost in variance. Compare with a threshold, clean the weights, check the KKT residuals, and if it matters tighten the tolerance or try a solver with polishing.
4. Your "maximise expected return" optimisation returns unbounded. What did you most likely forget?
A limit on risk or leverage. With only a budget, you can short the lowest forecast and buy the highest in unlimited size, so the objective has no ceiling. Add a gross-exposure cap, a volatility cap, or a risk penalty in the objective (the route Session 9.1 takes).
5. Volatility is a convex function of the weights. So why does cvxpy reject cp.Minimize(cp.sqrt(cp.quad_form(w, S)))?
DCP certifies convexity only through its composition rules, and sqrt is concave: no rule makes a concave function of a convex expression convex. The rules are sufficient, not necessary. Write volatility as what it is, a norm, cp.norm(Lc.T @ w, 2) with Σ=LcLc⊤\Sigma = L_c L_c^\top, 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?
B is interior, so its marginal variance 2(Σw)B2(\Sigma w)_B equals the budget price ν\nu. A's is ν+λA>ν\nu + \lambda_A \gt \nu: adding even a little of A would raise risk faster than anything you hold, which is exactly why the optimiser excludes it. That is stationarity plus complementary slackness, read as finance.
🔧 Homework (~1 h before Session 3.5)

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

  1. 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/
  2. 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
  3. 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
  4. 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
  5. 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

  1. 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
  2. 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
  3. 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
  4. Goulart, P. J. & Chen, Y. (2024). "Clarabel: An Interior-Point Solver for Conic Programs with Quadratic Objectives." arXiv:2405.12762
  5. 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
  6. 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
  7. 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/