edgekit.optimize
Portfolio optimization — turn a vector of expected returns and a covariance matrix into weights. Covariance estimators (sample and shrunk), the classic optimizers (min-variance, max-Sharpe, mean-variance), the efficient frontier, and the risk-parity family, all in closed numpy.
What's inside. Two covariance estimators (sample_cov, ledoit_wolf) feed everything downstream. The optimizers split into two camps: the unconstrained analytic solvers (min_variance, max_sharpe, mean_variance) that solve in closed form, and the iterative risk-based allocators (equal_risk_contribution). efficient_frontier traces the whole risk/return locus, and portfolio_vol / portfolio_return / risk_contributions are the small diagnostics you evaluate any weight vector with.
equal_risk_contribution (which stays non-negative by construction). The weights sum to 1 but are otherwise unbounded.Covariance estimators#
sample_cov#
The plain sample covariance matrix of a returns panel — the maximum-likelihood estimate. Fast and unbiased, but noisy and often near-singular when assets outnumber observations, which is exactly when you want to shrink it instead.
sample_cov(returns) -> np.ndarray # (n_assets, n_assets)returns— a(T, n)array/DataFrame of asset returns (rows = periods).
Returns: an (n, n) numpy covariance matrix.
import edgekit as ek
cov = ek.optimize.sample_cov(rets_panel)ledoit_wolf#
Ledoit-Wolf shrinkage covariance — pulls the noisy sample matrix toward a scaled-identity target by an analytically optimal intensity. The go-to estimator for optimization: it stays well-conditioned and invertible, which keeps max_sharpe and min_variance from blowing up on estimation noise.
ledoit_wolf(returns) -> np.ndarray # shrunk (n, n)returns— a(T, n)array/DataFrame of asset returns.
Returns: an (n, n) shrunk covariance matrix (same shape as sample_cov).
cov = ek.optimize.ledoit_wolf(rets_panel) # prefer this for optimization inputsOptimizers#
min_variance#
The global minimum-variance portfolio — the weights that minimise wᵀ Σ w subject only to summing to 1. Ignores expected returns entirely, which is a feature: it is the most estimation-robust point on the frontier because Σ is far easier to estimate than μ.
min_variance(cov) -> np.ndarray # unconstrained, sums to 1cov— an(n, n)covariance matrix.
Returns: an (n,) weight vector summing to 1 (can be negative).
w = ek.optimize.min_variance(cov)max_sharpe#
The tangency portfolio — weights that maximise the Sharpe ratio (wᵀμ − rf) / sqrt(wᵀΣw). The most sensitive of the three to estimation error in μ; always feed it a shrunk covariance and treat the result as a starting point, not gospel.
max_sharpe(mu, cov, rf=0.0) -> np.ndarray # tangency, unconstrained| Param | Type | Default | Meaning |
|---|---|---|---|
mu | array (n,) | — | Expected returns per asset. |
cov | array (n, n) | — | Covariance matrix (use ledoit_wolf). |
rf | float | 0.0 | Risk-free rate in the same units as mu. |
Returns: an (n,) weight vector summing to 1 (can be negative).
w = ek.optimize.max_sharpe(mu, cov, rf=0.02)mean_variance#
The Markowitz mean-variance solution — maximises wᵀμ − (risk_aversion/2)·wᵀΣw. Sweeping risk_aversion from small to large walks the weights from aggressive (return-seeking) toward the minimum-variance corner.
mean_variance(mu, cov, risk_aversion=1.0) -> np.ndarray| Param | Type | Default | Meaning |
|---|---|---|---|
mu | array (n,) | — | Expected returns per asset. |
cov | array (n, n) | — | Covariance matrix. |
risk_aversion | float | 1.0 | Risk penalty λ; higher = more conservative. |
Returns: an (n,) weight vector summing to 1 (can be negative).
w = ek.optimize.mean_variance(mu, cov, risk_aversion=3.0)efficient_frontier#
Trace the efficient frontier: n portfolios spanning the achievable return range, each the minimum-variance portfolio for its target return. The one call that gives you the whole risk/return locus to plot or to pick a point off.
efficient_frontier(mu, cov, n=50) -> dict| Param | Type | Default | Meaning |
|---|---|---|---|
mu | array (n_assets,) | — | Expected returns per asset. |
cov | array (n_assets, n_assets) | — | Covariance matrix. |
n | int | 50 | Number of frontier points to trace. |
Returns: a dict with keys "returns" (target return of each point), "vols" (its volatility), "weights" (an (n, n_assets) matrix of the weights at each point), and "sharpe" (the Sharpe of each point).
ef = ek.optimize.efficient_frontier(mu, cov, n=60)
ef["vols"], ef["returns"] # x, y to plot the frontier
best = ef["weights"][ef["sharpe"].argmax()] # max-Sharpe point on the frontier
Risk parity & contributions#
risk_contributions#
Decompose a portfolio's variance into each asset's share — the total risk contributed by each position, which sums to the portfolio volatility. The diagnostic that reveals when a “diversified” book is actually one bet in disguise.
risk_contributions(weights, cov) -> np.ndarray| Param | Type | Default | Meaning |
|---|---|---|---|
weights | array (n,) | — | The portfolio weights. |
cov | array (n, n) | — | Covariance matrix. |
Returns: an (n,) array of per-asset risk contributions (summing to portfolio vol).
rc = ek.optimize.risk_contributions(w, cov)
rc / rc.sum() # fractional risk share per assetequal_risk_contribution#
The risk-parity portfolio — iteratively solves for weights where every asset contributes the same amount of risk. Non-negative by construction (no shorting), and far more robust than max-Sharpe because it never touches μ. The workhorse allocator for a multi-strategy book.
equal_risk_contribution(cov, iters=200, tol=1e-8) -> np.ndarray| Param | Type | Default | Meaning |
|---|---|---|---|
cov | array (n, n) | — | Covariance matrix. |
iters | int | 200 | Maximum fixed-point iterations. |
tol | float | 1e-8 | Convergence tolerance on the weight update. |
Returns: an (n,) non-negative weight vector summing to 1.
w = ek.optimize.equal_risk_contribution(cov)
ek.optimize.risk_contributions(w, cov) # all roughly equalDiagnostics#
portfolio_vol#
Portfolio volatility for a weight vector — the square root of wᵀ Σ w.
portfolio_vol(weights, cov) -> floatweights— an(n,)weight vector.cov— the(n, n)covariance matrix.
Returns: a float portfolio volatility (same units as the covariance inputs).
portfolio_return#
Portfolio expected return for a weight vector — the dot product wᵀ μ.
portfolio_return(weights, mu) -> floatweights— an(n,)weight vector.mu— the(n,)expected-return vector.
Returns: a float expected portfolio return.
w = ek.optimize.max_sharpe(mu, cov)
r = ek.optimize.portfolio_return(w, mu)
v = ek.optimize.portfolio_vol(w, cov)
sharpe = r / vViews, denoising & turnover#
The classic optimizers above are only as good as the μ and Σ you feed them. This block attacks the estimation problem from three sides: black_litterman blends market-implied returns with your views, mp_denoise strips random-matrix noise out of a correlation matrix, michaud_resample averages the optimizer over bootstrap worlds, and turnover_penalized stops the rebalance itself from eating the edge.
black_litterman#
The Black-Litterman blend — start from the equilibrium returns implied by market weights (reverse optimization), then tilt them toward your views P·μ = q in proportion to the confidence omega. The cure for max-Sharpe's garbage-in-garbage-out: the posterior mu_bl is anchored to something sane and only moves where you actually have a view.
black_litterman(w_mkt, cov, views_p, views_q, tau=0.05, delta=2.5, omega=None) -> dict| Param | Type | Default | Meaning |
|---|---|---|---|
w_mkt | array (n,) | — | Market-cap (equilibrium) weights. |
cov | array (n, n) | — | Covariance matrix. |
views_p | array (k, n) | — | Pick matrix — one row per view, selecting/weighting assets. |
views_q | array (k,) | — | The expected return of each view portfolio. |
tau | float | 0.05 | Uncertainty scalar on the equilibrium prior. |
delta | float | 2.5 | Market risk-aversion used in reverse optimization. |
omega | array (k, k) | None | None | View-uncertainty matrix; default tau · diag(P Σ Pᵀ). |
Returns: a dict with keys "mu_bl" (the posterior expected returns) and "w_bl" (the weights they imply).
import numpy as np
P = np.array([[1, -1, 0]]) # view: asset 0 outperforms asset 1
q = np.array([0.02]) # ... by 2%
bl = ek.optimize.black_litterman(w_mkt, cov, P, q, tau=0.05)
bl["mu_bl"], bl["w_bl"]mp_denoise#
Marchenko-Pastur denoising of a correlation matrix — eigenvalues below the random-matrix upper edge λ₊ are indistinguishable from noise, so they are clipped to their average while the signal eigenvalues are kept. Feed the result (rescaled back to covariance) to any optimizer above; it is the single cheapest fix for unstable weights when assets are many and history is short.
mp_denoise(corr, n_obs) -> np.ndarray| Param | Type | Default | Meaning |
|---|---|---|---|
corr | array (n, n) | — | Correlation matrix to denoise. |
n_obs | int | — | Number of observations behind it (sets the MP edge λ₊). |
Returns: the denoised (n, n) correlation matrix (unit diagonal preserved).
corr = np.corrcoef(rets_panel.T)
clean = ek.optimize.mp_denoise(corr, n_obs=len(rets_panel))
cov_clean = clean * np.outer(vols, vols) # back to covariance for the optimizersmichaud_resample#
Michaud resampled efficiency — bootstrap n_boot alternative (μ, Σ) worlds, optimize in each, and average the weights. The averaging smooths away the corner solutions a single noisy estimate produces, at the cost of some optimality in the (unknowable) true world. Deterministic given a seeded rng.
michaud_resample(mu, cov, n_boot=200, long_only=True, rng=None) -> np.ndarray| Param | Type | Default | Meaning |
|---|---|---|---|
mu | array (n,) | — | Expected returns per asset. |
cov | array (n, n) | — | Covariance matrix. |
n_boot | int | 200 | Number of bootstrap resamples. |
long_only | bool | True | Clip each resampled solution to non-negative weights. |
rng | Generator | None | None | numpy Generator; defaults to bootstrap_rng(). |
Returns: an (n,) averaged weight vector summing to 1.
w = ek.optimize.michaud_resample(mu, cov, n_boot=500) # smoother than max_sharpe(mu, cov)turnover_penalized#
Mean-variance utility with an L1 penalty on the trade away from your current weights — maximise wᵀμ − (risk_aversion/2)·wᵀΣw − gamma_tc·|w − w_prev|₁, solved by an iterative proximal (projected-gradient) scheme. The penalty creates a no-trade zone: small signal changes leave the book alone, and only moves worth more than their cost happen.
turnover_penalized(mu, cov, w_prev, gamma_tc=1.0, risk_aversion=2.5) -> np.ndarray| Param | Type | Default | Meaning |
|---|---|---|---|
mu | array (n,) | — | Expected returns per asset. |
cov | array (n, n) | — | Covariance matrix. |
w_prev | array (n,) | — | The weights you currently hold. |
gamma_tc | float | 1.0 | L1 transaction-cost penalty per unit of turnover. |
risk_aversion | float | 2.5 | Risk penalty λ in the utility. |
Returns: an (n,) weight vector — close to w_prev when the signal change is small relative to gamma_tc.
w_new = ek.optimize.turnover_penalized(mu, cov, w_prev=w_now, gamma_tc=2.0)
np.abs(w_new - w_now).sum() # the turnover you actually pay forSee also#
- edgekit.factors — estimate the
μand betas that feed the optimizers. - edgekit.risk — VaR/CVaR on the resulting portfolio return stream.
- edgekit.metrics — score the optimized book on PF / MAR / Sharpe.
