Modern Portfolio Theory and Mean-Variance Optimization › Quadratic Programming for Portfolio Optimization

Michael BrenndoerferDecember 13, 20257 min read
Quantitative FinanceAll chapters
Part 9 of 15Portfolio Theory and Investment Analysis
Chapter 46Modern Portfolio Theory and Mean-Variance Optimization › Quadratic Programming for Portfolio Optimization
Reading section 6 of 12 · Quadratic Programming for Portfolio Optimization · 7 minSection 6 of 12 · 7 min

Book contents

Section 6 of 12 · 7 min read

Modern Portfolio Theory and Mean-Variance Optimization › Quadratic Programming for Portfolio OptimizationOne section · Sections in this chapter6 / 12 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

For practical applications with many assets and constraints, we use quadratic programming (QP). Quadratic programming is a mathematical optimization technique designed specifically for problems where the objective function is quadratic (involving squared terms) and the constraints are linear. Mean-variance optimization fits this structure perfectly because portfolio variance is a quadratic function of the weights.

The standard QP form is:

min⁡x12x⊤Qx+c⊤xsubject toAeqx=beqAineqx≤bineq\begin{aligned} \min_{\mathbf{x}} \quad & \frac{1}{2}\mathbf{x}^\top Q \mathbf{x} + \mathbf{c}^\top \mathbf{x} \\ \text{subject to} \quad & A_{\text{eq}}\mathbf{x} = \mathbf{b}_{\text{eq}} \\ & A_{\text{ineq}}\mathbf{x} \leq \mathbf{b}_{\text{ineq}} \end{aligned}

where:

  • x\mathbf{x}: vector of decision variables
  • QQ: symmetric matrix (quadratic term)
  • c\mathbf{c}: vector (linear term)
  • Aeq,beqA_{\text{eq}}, \mathbf{b}_{\text{eq}}: matrix and vector defining equality constraints
  • Aineq,bineqA_{\text{ineq}}, \mathbf{b}_{\text{ineq}}: matrix and vector defining inequality constraints

The minimum-variance formulation uses Q=ΣQ = \Sigma and c=0\mathbf{c} = \mathbf{0}. For the defined utility U=w⊤μ−γ2w⊤ΣwU=\mathbf{w}^\top\boldsymbol{\mu}-\frac{\gamma}{2}\mathbf{w}^\top\Sigma\mathbf{w}, minimizing −U-U uses Q=γΣQ = \gamma\Sigma and c=−μ\mathbf{c} = -\boldsymbol{\mu}. Equivalently, dividing the objective by positive γ\gamma gives Q=ΣQ=\Sigma and c=−μ/γ\mathbf{c}=-\boldsymbol{\mu}/\gamma. The analytical exact-target formulation enforces both budget and target return as equalities; the utility formulation does not require a target-return equality. Inequalities can encode position limits and other practical requirements.

Adding Practical Constraints

Real portfolio optimization often includes additional constraints that reflect regulatory requirements, risk management policies, or investment mandates:

  • No short selling: wi≥0w_i \geq 0 for all ii. This constraint prevents the portfolio from taking negative positions, which an investor cannot or chooses not to take.
  • Maximum position size: wi≤wmax⁡w_i \leq w_{\max} (e.g., 10% per asset). This limits single-security concentration and can encourage diversification, but does not guarantee diversified sector or factor exposure.
  • Sector constraints: Total exposure to a sector ≤\leq some limit. This prevents the portfolio from becoming overly exposed to a single industry or market segment.
  • Turnover constraints: Changes from current portfolio are limited. This controls transaction costs and tax consequences by restricting how much trading occurs.

Let's implement a more realistic optimizer using SciPy's SLSQP method. Here target_return is a minimum return floor, imposed as an inequality, rather than the exact-target equality used in the analytical derivation. If the solver reports failure, the function raises an error instead of presenting a fallback allocation as optimal:

This implementation uses the ridge-regularized covariance Σ+10−8I\Sigma+10^{-8}I, so its numerical candidate targets that model, not exactly the supplied unregularized covariance problem. The fixed ridge can matter substantially when covariance entries are small. For tiny objectives, we divide the entire chosen objective by a positive scale to avoid premature termination under an absolute stopping tolerance; this does not change its mathematical minimizer. A successful solver flag is not a proof of global optimality. After thresholding and normalization, we check finite weights, budget, bounds and the return floor to an absolute feasibility tolerance of 10−710^{-7}; this is a feasibility check, not an optimality certificate.

In[16]:
Code
import numpy as np
from scipy.optimize import minimize


def mean_variance_optimize(
    mu,
    cov_matrix,
    target_return=None,
    risk_aversion=None,
    max_weight=None,
    min_weight=0.0,
    allow_short=False,
):
    """
    Find an approximate feasible candidate for a ridge-regularized model.

    Requires finite, compatible inputs and a positive-semidefinite covariance;
    the utility branch assumes positive risk_aversion. Uses Sigma + 1e-8 I.
    Tiny entire objectives are positively rescaled; success is not an
    optimality proof. Returned feasibility uses an absolute 1e-7 tolerance.

    Parameters
    ----------
    mu : ndarray
        Expected returns
    cov_matrix : ndarray
        Covariance matrix
    target_return : float, optional
        Minimum portfolio return (a floor for min variance formulation)
    risk_aversion : float, optional
        Risk aversion parameter (for max utility formulation)
    max_weight : float, optional
        Maximum weight per asset
    min_weight : float, optional
        Minimum weight per asset when allow_short=False (default 0).
        Ignored when allow_short=True, which sets the lower bound to -1.
    allow_short : bool
        Use lower bound -1 rather than min_weight; the default upper bound is 1.

    Returns
    -------
    weights : ndarray
        Approximate feasible weights for the ridge-regularized model

    Raises
    ------
    RuntimeError
        If the optimizer reports failure or returned weights fail validation.
    """
    # Convert to numpy arrays and ensure proper shape
    mu_arr = np.asarray(mu).flatten()
    cov_arr = np.asarray(cov_matrix)
    n = len(mu_arr)

    # Fixed ridge changes the supplied covariance model; see the stated scope.
    cov_arr = cov_arr + np.eye(len(mu_arr)) * 1e-8

    if target_return is not None:
        objective = lambda w: float(w @ cov_arr @ w)
    elif risk_aversion is not None:
        objective = lambda w: (
            -float(mu_arr @ w - (risk_aversion / 2) * (w @ cov_arr @ w))
        )
    else:
        objective = lambda w: float(w @ cov_arr @ w)

    objective_scale = float(np.max(np.abs(cov_arr)))
    if target_return is None and risk_aversion is not None:
        objective_scale = max(
            float(np.max(np.abs(mu_arr))), abs(risk_aversion) * objective_scale
        )
    if 0 < objective_scale < 1e-6:
        unscaled_objective = objective
        objective = lambda w: unscaled_objective(w) / objective_scale

    constraints = [{"type": "eq", "fun": lambda w: np.sum(w) - 1.0}]
    if target_return is not None:
        constraints.append(
            {"type": "ineq", "fun": lambda w: float(mu_arr @ w - target_return)}
        )

    if allow_short:
        lower = -1.0
    else:
        lower = min_weight
    upper = 1.0 if max_weight is None else max_weight
    bounds = [(lower, upper) for _ in range(n)]

    x0 = np.full(n, 1.0 / n)
    if not allow_short:
        x0 = np.clip(x0, lower, upper)
        x0 = x0 / x0.sum()

    result = minimize(
        objective,
        x0,
        method="SLSQP",
        bounds=bounds,
        constraints=constraints,
        options={"maxiter": 500, "ftol": 1e-10},
    )

    if not result.success:
        raise RuntimeError(f"Mean-variance optimization failed: {result.message}")
    weights = result.x

    weights = np.asarray(weights, dtype=float)
    if not np.all(np.isfinite(weights)):
        raise RuntimeError("Mean-variance optimization returned invalid weights")
    weights[np.abs(weights) < 1e-10] = 0.0
    total = weights.sum()
    if not np.isfinite(total) or total == 0:
        raise RuntimeError("Mean-variance optimization returned invalid weights")
    weights = weights / total
    feasibility_tolerance = 1e-7
    if (
        abs(weights.sum() - 1.0) > feasibility_tolerance
        or np.any(weights < lower - feasibility_tolerance)
        or np.any(weights > upper + feasibility_tolerance)
        or (
            target_return is not None
            and mu_arr @ weights < target_return - feasibility_tolerance
        )
    ):
        raise RuntimeError("Mean-variance optimization returned infeasible weights")
    return weights
In[17]:
Code
# Unconstrained GMV (allows short selling)
w_unconstrained = global_minimum_variance_portfolio(cov_matrix)

# Constrained: no short selling
w_long_only = mean_variance_optimize(mu, cov_matrix, allow_short=False)

# Constrained: max 40% per asset, no short selling
max_pos = 0.4
w_max_40 = mean_variance_optimize(mu, cov_matrix, allow_short=False, max_weight=max_pos)

# Calculate performance metrics
ret_unconstrained = w_unconstrained @ mu
risk_unconstrained = np.sqrt(w_unconstrained @ cov_matrix @ w_unconstrained)

ret_long_only = w_long_only @ mu
risk_long_only = np.sqrt(w_long_only @ cov_matrix @ w_long_only)

ret_max_40 = w_max_40 @ mu
risk_max_40 = np.sqrt(w_max_40 @ cov_matrix @ w_max_40)
Out[18]:
Console
Portfolio Weights Comparison:
------------------------------------------------------------
Asset             Unconstrained       Long-Only         Max 40%
------------------------------------------------------------
Stocks                    2.95%           2.95%          33.78%
Bonds                    92.03%          92.03%          40.00%
Commodities               5.02%           5.02%          26.22%
------------------------------------------------------------

Unconstrained:
  Return: 3.25%, Risk: 4.82%

Long-Only:
  Return: 3.25%, Risk: 4.82%

Max 40%:
  Return: 5.21%, Risk: 9.49%
Out[19]:
Visualization
Grouped bars with approximately identical unrestricted and long-only allocations near 3% Stocks, 92% Bonds, and 5% Commodities; the 40%-cap allocation is about 34%, 40%, and 26%.
GMV allocations under three constraint regimes. The original-model unrestricted optimum is already long-only; the ridge-regularized numerical long-only candidate agrees to displayed precision. The 40% cap changes the allocation and raises original-covariance volatility from 4.82% to 9.49%.

In the original covariance model, the unrestricted GMV optimum is already long-only, so that constraint is nonbinding. The 40% cap raises volatility substantially, from 4.82% to 9.49%. Adding constraints cannot improve the minimum objective when the objective itself is unchanged. Here the numerical constrained helper also adds a small ridge, so exact equality with the unrestricted calculation is only expected to displayed precision.