QP Portfolio › Implementation

Michael BrenndoerferSeptember 7, 20258 min read
Machine Learning from ScratchAll chapters
Part 9 of 9Optimization
Chapter 64QP Portfolio › Implementation
Reading section 5 of 8 · Implementation · 8 minSection 5 of 8 · 8 min

Book contents

Section 5 of 8 · 8 min read

QP Portfolio › ImplementationOne section · Sections in this chapter5 / 8 ↓
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

While the closed-form solution provides insight into the mathematical structure, practical portfolio optimization requires numerical methods that can handle additional constraints like no-short-selling requirements. We'll implement portfolio optimization using scipy's optimization tools, translating the mathematical formulation directly into code.

Setting Up the Optimization Function

First, we'll create a function that sets up and solves the portfolio optimization problem. This function takes expected returns, a covariance matrix, and an optional target return as inputs.

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


def optimize_portfolio(expected_returns, cov_matrix, target_return=None):
    """
    Optimize portfolio using quadratic programming

    Parameters:
    - expected_returns: array of expected returns for each asset
    - cov_matrix: covariance matrix of returns
    - target_return: target expected return (if None, finds minimum variance)

    Returns:
    - optimal weights as numpy array
    """
    n_assets = len(expected_returns)

    # Objective function: minimize portfolio variance
    def objective(weights):
        return np.dot(weights.T, np.dot(cov_matrix, weights))

    # Budget constraint: weights must sum to 1
    constraints = [{"type": "eq", "fun": lambda w: np.sum(w) - 1}]

    # Return constraint (if target return is specified)
    if target_return is not None:
        constraints.append(
            {
                "type": "eq",
                "fun": lambda w: np.sum(w * expected_returns) - target_return,
            }
        )

    # Bounds: no short selling (weights between 0 and 1)
    bounds = tuple((0, 1) for _ in range(n_assets))

    # Initial guess: equal weights
    x0 = np.array([1 / n_assets] * n_assets)

    # Solve using Sequential Least Squares Programming (SLSQP)
    result = minimize(
        objective, x0, method="SLSQP", bounds=bounds, constraints=constraints
    )

    return result.x if result.success else None

Finding the Minimum Variance Portfolio

Let's optimize for the minimum variance portfolio, which represents the lowest-risk allocation possible given our assets.

In[11]:
Code
# Optimize for minimum variance portfolio
optimal_weights = optimize_portfolio(expected_returns.values, cov_matrix.values)

# Calculate portfolio statistics
portfolio_return = np.sum(optimal_weights * expected_returns.values)
portfolio_variance = np.dot(
    optimal_weights, np.dot(cov_matrix.values, optimal_weights)
)
portfolio_risk = np.sqrt(portfolio_variance)
sharpe_ratio = portfolio_return / portfolio_risk
Out[12]:
Console
Minimum Variance Portfolio:
==================================================

Optimal Asset Weights:
  Stocks      : 0.0001 (  0.01%)
  Bonds       : 0.0096 (  0.96%)
  Commodities : 0.0012 (  0.12%)
  REITs       : 0.0025 (  0.25%)
  Cash        : 0.9867 ( 98.67%)

Portfolio Statistics:
  Expected Return: 0.0205 (2.05%)
  Portfolio Risk:  0.0099 (0.99%)
  Sharpe Ratio:    2.0606

The minimum variance portfolio concentrates in low-volatility assets, primarily Cash and Bonds, while avoiding high-volatility assets like Commodities. This allocation achieves the lowest possible portfolio risk given the available assets. The expected return is modest (typically 2-4% for this asset mix). A Sharpe ratio above 0.5 indicates reasonable risk-adjusted performance; values above 1.0 are considered strong.

Optimizing for a Target Return

Now let's optimize for a specific target return of 8% to see how the optimal allocation changes when we require higher returns.

In[13]:
Code
# Optimize for 8% target return
target_return = 0.08
weights_target = optimize_portfolio(
    expected_returns.values, cov_matrix.values, target_return=target_return
)

# Calculate statistics for target return portfolio
ret_target = np.sum(weights_target * expected_returns.values)
var_target = np.dot(weights_target, np.dot(cov_matrix.values, weights_target))
risk_target = np.sqrt(var_target)
sharpe_target = ret_target / risk_target
Out[14]:
Console
Portfolio Optimized for 8% Target Return:
==================================================

Optimal Asset Weights:
  Stocks      : 0.2938 ( 29.38%)
  Bonds       : 0.1950 ( 19.50%)
  Commodities : 0.0447 (  4.47%)
  REITs       : 0.3004 ( 30.04%)
  Cash        : 0.1660 ( 16.60%)

Portfolio Statistics:
  Expected Return: 0.0800 (8.00%)
  Portfolio Risk:  0.1007 (10.07%)
  Sharpe Ratio:    0.7946

To achieve 8% expected return, the optimizer shifts weight toward Stocks and REITs while reducing Cash and Bonds. The resulting portfolio risk increases substantially. Compare this to the minimum variance portfolio to see the risk cost of higher returns. If the Sharpe ratio improves (or remains similar), the additional return justifies the extra risk. If it decreases significantly, consider whether the target return is realistic given the available assets.

Comparing Portfolios

Let's compare the minimum variance portfolio with the target return portfolio to see the trade-offs.

In[15]:
Code
# Create comparison data
comparison_data = {
    "Metric": ["Expected Return", "Portfolio Risk", "Sharpe Ratio"],
    "Minimum Variance": [
        f"{portfolio_return * 100:.2f}%",
        f"{portfolio_risk * 100:.2f}%",
        f"{sharpe_ratio:.3f}",
    ],
    "Target Return (8%)": [
        f"{ret_target * 100:.2f}%",
        f"{risk_target * 100:.2f}%",
        f"{sharpe_target:.3f}",
    ],
}
Out[16]:
Console
Portfolio Comparison:
==================================================
         Metric Minimum Variance Target Return (8%)
Expected Return            2.05%              8.00%
 Portfolio Risk            0.99%             10.07%
   Sharpe Ratio            2.061              0.795

This comparison illustrates the efficient frontier trade-off. Moving from minimum variance to an 8% target return increases portfolio risk by roughly 2-3x, a significant jump for a 4-5 percentage point return increase. The Sharpe ratios indicate whether this trade-off is favorable: if both portfolios have similar Sharpe ratios, the choice depends purely on risk tolerance. If the target portfolio has a lower Sharpe ratio, the additional return may not justify the extra risk.

Alternative Implementation: Custom QP Solver

To connect our theoretical derivation with code, let's implement the Lagrange multiplier solution from the Example section. This solver directly constructs and solves the KKT system we derived earlier:

In[17]:
Code
def solve_qp_custom(cov_matrix, expected_returns, target_return=None):
    """
    Custom QP solver using the method of Lagrange multipliers
    Solves the minimum variance portfolio problem analytically
    """
    n = len(expected_returns)

    if target_return is None:
        # Minimum variance portfolio
        # Solve: min (1/2) * w^T * Sigma * w subject to sum(w) = 1
        # Using method of Lagrange multipliers

        # The optimality conditions are:
        #   Sigma * w - lambda * 1 = 0  (gradient condition)
        #   1^T * w = 1                  (constraint)
        #
        # Rearranging: Sigma * w = lambda * 1
        # In matrix form: [2*Sigma, -1; 1^T, 0] * [w; lambda] = [0; 1]
        # The factor of 2 comes from the Hessian (second derivative) of (1/2)w^T*Sigma*w
        A = np.zeros((n + 1, n + 1))
        A[:n, :n] = 2 * cov_matrix  # 2 * Sigma (Hessian of the objective)
        A[:n, n] = -1  # negative constraint coefficients for lambda
        A[n, :n] = 1  # constraint coefficients (1^T)
        A[n, n] = 0  # zero in bottom-right

        b = np.zeros(n + 1)
        b[n] = 1  # constraint value

        solution = np.linalg.solve(A, b)
        weights = solution[:n]

    else:
        # Target return portfolio
        # Solve: min w^T * Sigma * w subject to sum(w) = 1 and sum(w*mu) = target
        A = np.zeros((n + 2, n + 2))
        A[:n, :n] = 2 * cov_matrix
        A[:n, n] = -1  # budget constraint coefficient for lambda1
        A[
            :n, n + 1
        ] = -expected_returns  # return constraint coefficient for lambda2
        A[n, :n] = 1
        A[n + 1, :n] = expected_returns

        b = np.zeros(n + 2)
        b[n] = 1  # budget constraint
        b[n + 1] = target_return  # return constraint

        solution = np.linalg.solve(A, b)
        weights = solution[:n]

    return weights


# Test the custom solver
weights_custom = solve_qp_custom(cov_matrix.values, expected_returns.values)
ret_custom = np.sum(weights_custom * expected_returns.values)
var_custom = np.dot(weights_custom, np.dot(cov_matrix.values, weights_custom))
risk_custom = np.sqrt(var_custom)
Out[18]:
Console
Custom QP Solver Results (Minimum Variance Portfolio):
==================================================

Optimal Asset Weights:
  Stocks      : 0.0001 (  0.01%)
  Bonds       : 0.0096 (  0.96%)
  Commodities : 0.0012 (  0.12%)
  REITs       : 0.0025 (  0.25%)
  Cash        : 0.9866 ( 98.66%)

Portfolio Statistics:
  Expected Return: 0.0205 (2.05%)
  Portfolio Risk:  0.0099 (0.99%)

Validation:
  Max weight difference vs scipy: 9.07e-05

The custom solver produces the same weights as scipy's numerical optimizer (difference < 10−610^{-6}), confirming both methods solve the same mathematical problem correctly. The analytical approach is faster for small portfolios without inequality constraints, while scipy's SLSQP handles no-short-selling bounds and other inequality constraints that the closed-form solution cannot accommodate.

Key Parameters

Below are the main parameters that affect how portfolio optimization works and performs.

  • expected_returns: Array of annualized expected returns for each asset. Estimate from historical means or factor models. Errors in return estimates significantly impact optimal weights, so consider using shrinkage toward the grand mean or focusing on minimum variance (which ignores returns).

  • cov_matrix: Annualized covariance matrix of asset returns. Must be positive semi-definite. Use sklearn.covariance.LedoitWolf for shrinkage estimation when assets-to-observations ratio exceeds 0.3. Sample covariances are unreliable when this ratio exceeds 0.5.

  • target_return: Target annualized return for the portfolio. Must be between min and max individual asset returns to be feasible. Set to None for minimum variance portfolio. Start with conservative targets (e.g., 2-3% above minimum variance return) to avoid extreme allocations.

  • bounds: Weight constraints per asset, typically (0, 1) to prevent short selling. Use (0, 0.3) to cap any single position at 30%. Set (-0.3, 1) to allow limited short selling. More restrictive bounds reduce optimization benefit but improve stability.

  • method: Optimization algorithm. Use 'SLSQP' (default) for most problems since it handles equality and inequality constraints efficiently. Use 'trust-constr' for very large problems (500+ assets) or when SLSQP fails to converge.

  • constraints: List of constraint dictionaries with 'type' ('eq' for equality, 'ineq' for inequality) and 'fun' (function returning 0 when satisfied for eq, or ≥0 for ineq). The budget constraint sum(w) = 1 should be included in all portfolio optimization problems.

Key Methods

The following are the most commonly used methods for portfolio optimization.

  • optimize_portfolio(expected_returns, cov_matrix, target_return=None): Main wrapper function defined above. Returns optimal weights as a numpy array. Use target_return=None for minimum variance; specify a float (e.g., 0.08 for 8%) for target return optimization.

  • scipy.optimize.minimize(objective, x0, method, bounds, constraints): Core scipy function. The objective computes portfolio variance (wTΣww^T \Sigma w), x0 is the initial guess (equal weights work well), bounds constrain individual weights, and constraints enforce budget and return requirements. Check result.success to verify convergence.