QP Portfolio › Implementation
Machine Learning from ScratchAll chapters 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.
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 NoneFinding the Minimum Variance Portfolio
Let's optimize for the minimum variance portfolio, which represents the lowest-risk allocation possible given our assets.
# 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_riskMinimum 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.
# 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_targetPortfolio 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.
# 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}",
],
}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.795This 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:
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)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 < ), 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. Usesklearn.covariance.LedoitWolffor 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 toNonefor 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 constraintsum(w) = 1should 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. Usetarget_return=Nonefor minimum variance; specify a float (e.g.,0.08for 8%) for target return optimization. -
scipy.optimize.minimize(objective, x0, method, bounds, constraints): Core scipy function. Theobjectivecomputes portfolio variance (),x0is the initial guess (equal weights work well),boundsconstrain individual weights, andconstraintsenforce budget and return requirements. Checkresult.successto verify convergence.