portfolio_optimization
This is the classical toolbox: given expected returns and a covariance matrix ,
find weights that minimise risk, maximise the Sharpe ratio, or hit a return target at least
risk, subject to bounds on each weight. AFML’s Chapter 16 exists because these portfolios are
fragile, and hrp is the book’s alternative. They remain the benchmark
every alternative is measured against, and with sensible bounds they are often good enough.
The Python module is openquant.portfolio.
Four solutions
Section titled “Four solutions”All four are long-only and fully invested by default, and .
solution | Problem |
|---|---|
"inverse_variance" | weights proportional to the reciprocal of each variance; ignores correlation, then clamps to the bounds |
"min_volatility" | minimise portfolio variance |
"max_sharpe" | maximise excess return over risk_free_rate per unit of volatility |
"efficient_risk" | minimise portfolio variance subject to an expected return of at least target_return |
Written out, the last is
and minimum volatility is the same without the return constraint. Maximum Sharpe is not a quadratic programme as stated, since it is a ratio. It becomes one under the substitution with : minimise subject to , with the bounds rewritten as so that they stay linear, then recover .
The three optimisations go to an internal solver, ADMM in the OSQP formulation (Stellato et al., 2020) followed by an exact solve on the active constraints it identifies. Bounds are part of the problem. The closed-form minimum-variance solution shorts assets, and clipping it to zero afterwards is not the long-only optimum; the solver finds the constrained one.
Bounds come two ways. tuple_bounds = (lo, hi) applies to every asset; bounds sets them per
asset by index and takes precedence (a HashMap<usize, (f64, f64)> in Rust, a list of
(index, lo, hi) in Python).
from openquant import portfolio
# Expected annual returns and an annual covariance matrix for four assets.names = ["bonds", "equity", "small cap", "gold"]mu = [0.03, 0.07, 0.09, 0.04]vol = [0.05, 0.16, 0.22, 0.15]rho = [[1.0, 0.1, 0.1, 0.1], [0.1, 1.0, 0.8, 0.0], [0.1, 0.8, 1.0, 0.0], [0.1, 0.0, 0.0, 1.0]]cov = [[rho[i][j] * vol[i] * vol[j] for j in range(4)] for i in range(4)]
def show(label, result): weights, risk, ret, _ = result cells = " ".join(f"{round(w, 2) + 0.0:5.2f}" for w in weights) print(f"{label:24s} {cells} return {ret:.3f} risk {risk:.3f}")
print(" " * 25 + " ".join(f"{n[:5]:>5s}" for n in names))show("inverse variance", portfolio.allocate_from_inputs(mu, cov, "inverse_variance"))show("minimum volatility", portfolio.allocate_from_inputs(mu, cov, "min_volatility"))show("maximum Sharpe", portfolio.allocate_from_inputs(mu, cov, "max_sharpe", risk_free_rate=0.02))show("6.5% return, least risk", portfolio.allocate_from_inputs(mu, cov, "efficient_risk", target_return=0.065))show("same, 35% cap per asset", portfolio.allocate_from_inputs(mu, cov, "efficient_risk", target_return=0.065, tuple_bounds=(0.0, 0.35)))
# Markowitz's curse: nudge one expected return by half a percentage point.base = portfolio.allocate_from_inputs(mu, cov, "max_sharpe", risk_free_rate=0.02)[0]nudged = portfolio.allocate_from_inputs([0.03, 0.075, 0.09, 0.04], cov, "max_sharpe", risk_free_rate=0.02)[0]print("equity +0.5pt moves weights by:", " ".join(f"{round(b - a, 2) + 0.0:+.2f}" for a, b in zip(base, nudged))) bonds equit small goldinverse variance 0.79 0.08 0.04 0.09 return 0.036 risk 0.049minimum volatility 0.87 0.06 0.00 0.07 return 0.033 risk 0.048maximum Sharpe 0.55 0.17 0.15 0.14 return 0.047 risk 0.0696.5% return, least risk 0.14 0.27 0.36 0.23 return 0.065 risk 0.124same, 35% cap per asset 0.13 0.29 0.35 0.23 return 0.065 risk 0.124equity +0.5pt moves weights by: -0.02 +0.09 -0.06 +0.00The last line is the argument of AFML §16.3 in one number. Raising the expected return of
equity from 7.0% to 7.5%, a change far smaller than anyone’s ability to forecast it, moves
nine points of the portfolio into equity, six of them out of its close substitute. Equity
and small cap are correlated at 0.8, so the optimiser treats them as nearly interchangeable
and swings between them on small differences in (Michaud, 1989, called it error
maximisation). The remedies are the ones on this site: bounds, as in the fifth row; leaving
out altogether, as minimum volatility and hrp do; or a better
covariance estimate.
From prices
Section titled “From prices”allocate_min_vol, allocate_max_sharpe, allocate_efficient_risk,
allocate_inverse_variance and allocate_with_solution(prices, solution, options) take a
matrix of prices, rows by date and columns by asset, and estimate the inputs themselves:
simple returns , the same convention as cla,
hrp and hcaa; an expected return that is either
their mean (ReturnsMethod::Mean) or an exponentially weighted mean
(ReturnsMethod::Exponential { span }); and their sample covariance. Simple returns are the
consistent choice for a one-period problem on weights, because a portfolio’s simple return is
the weighted sum of its assets’ simple returns, which is not true of log returns.
Everything is annual. The expected returns and the covariance are multiplied by the
number of periods in a year, , so portfolio_return is
, portfolio_risk is the annualised volatility , and
portfolio_sharpe is in annual units. Give
risk_free_rate and target_return as annual figures. compute_expected_and_covariance
returns the same annualised and without solving anything, so passing them to
allocate_from_inputs reproduces the result from prices. resample_by of "W" or "M"
keeps every 5th or 21st row first, and the annualisation factor becomes 252/5 or 252/21.
From Rust
Section titled “From Rust”use nalgebra::DMatrix;use openquant::portfolio_optimization::{allocate_from_inputs, AllocError, AllocationOptions};
let mu = [0.03, 0.07, 0.09, 0.04];let vol = [0.05, 0.16, 0.22, 0.15];let rho = [[1.0, 0.1, 0.1, 0.1], [0.1, 1.0, 0.8, 0.0], [0.1, 0.8, 1.0, 0.0], [0.1, 0.0, 0.0, 1.0]];let cov = DMatrix::from_fn(4, 4, |i, j| rho[i][j] * vol[i] * vol[j]);
let min_vol = allocate_from_inputs(&mu, &cov, "min_volatility", &AllocationOptions::default())?;assert!((min_vol.weights.iter().sum::<f64>() - 1.0).abs() < 1e-9);assert!(min_vol.weights.iter().all(|w| *w > -1e-9));assert!((min_vol.portfolio_risk - 0.0476).abs() < 1e-4);
// A return target above the minimum-variance return binds exactly, and costs risk.let target = AllocationOptions { target_return: 0.065, ..AllocationOptions::default() };let efficient = allocate_from_inputs(&mu, &cov, "efficient_risk", &target)?;assert!((efficient.portfolio_return - 0.065).abs() < 1e-7);assert!(efficient.portfolio_risk > min_vol.portfolio_risk);
// With a 35% cap the most any portfolio can return is 6.8%, so 7% is infeasible...let capped = AllocationOptions { target_return: 0.07, tuple_bounds: Some((0.0, 0.35)), ..AllocationOptions::default() };assert!(matches!( allocate_from_inputs(&mu, &cov, "efficient_risk", &capped), Err(AllocError::OptimizationFailed(_))));// ...and bounds that cannot sum to one are rejected before solving.let impossible = AllocationOptions { tuple_bounds: Some((0.3, 0.4)), ..AllocationOptions::default() };assert!(matches!( allocate_from_inputs(&mu, &cov, "min_volatility", &impossible), Err(AllocError::InfeasibleBounds { .. })));What to watch for
Section titled “What to watch for”- With
allocate_from_inputsthe units are yours.portfolio_sharpeis in whatever units , andrisk_free_ratewere given in, so they must agree: an annual with a daily overstates the Sharpe ratio by . The weights do not depend on the scale of , only the reported risk and Sharpe ratio do. portfolio_sharpeis reported for every solution, not only"max_sharpe"; it is the Sharpe ratio of the portfolio returned, againstrisk_free_rate. It is 0 only when the portfolio’s risk is 0. Before #110 was fixed it was 0 for the other three solutions, and from prices it divided an annual return by a daily volatility, so it read about 16 times () too high.- It is in-sample. The maximum-Sharpe portfolio’s
portfolio_sharpeis the best ratio on the history it was fitted to, by construction, and overstates what the portfolio will earn. - A target return that cannot be met is reported as
OptimizationFailed, with the text “no portfolio satisfies the constraints”. The same error covers a solver that did not converge. Bounds that cannot sum to one are caught earlier asInfeasibleBounds, with the sums in the message. "efficient_risk"treats the target as a floor. A target below the minimum-variance portfolio’s return gives the minimum-variance portfolio, not a deliberately worse one."max_sharpe"needs at least one asset above the risk-free rate, and says so otherwise.- The solver is dense and meant for tens of assets. Each iteration is a dense back-substitution, and it has been tested at that scale. Thousands of assets call for a sparse solver.
- The covariance is the plain sample covariance. With more assets than a few dozen
observations per asset it is poorly conditioned, and every weakness described above gets
worse. Shrink it, or denoise it, before passing it to
allocate_from_inputs.
Related modules
Section titled “Related modules”cla— the whole efficient frontier from the same inputs, by the critical line algorithm. From the same prices, or the same and , the two modules find the same maximum-Sharpe portfolio.hrp,hcaa— allocation without inverting or estimating .risk-metrics— tail risk of the resulting portfolio.codependence,onc— structure in the correlation matrix, for constraints by cluster.
API reference
Section titled “API reference”Python
Section titled “Python”Signatures, parameters, return values and errors for every function: openquant.portfolio.
portfolio.allocate_from_inputs— Allocate a mean-variance portfolio from given expected returns and covariance.portfolio.allocate_with_solution— Allocate a mean-variance portfolio from a price matrix with a named solution.portfolio.allocate_inverse_variance— Inverse-variance portfolio from a price matrix, long-only and fully invested.portfolio.allocate_min_vol— Minimum-volatility portfolio from a price matrix, `min w' Sigma w` with weight bounds.portfolio.allocate_max_sharpe— Maximum-Sharpe portfolio from a price matrix, with weight bounds.portfolio.allocate_efficient_risk— Least-risk portfolio from a price matrix whose expected return is at least a target.
Rustdoc for this commit, with every public item of openquant::portfolio_optimization (after a crates.io release the same pages are on docs.rs):