edgekit

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.

!min_variance / max_sharpe / mean_variance are unconstrained
These three are closed-form solvers that allow negative weights — they can and will short. There is no long-only or box constraint baked in. If your book is long-only or capped, clip and renormalise the result yourself, or reach for 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 inputs

Optimizers#

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 1
  • cov — 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
ParamTypeDefaultMeaning
muarray (n,)Expected returns per asset.
covarray (n, n)Covariance matrix (use ledoit_wolf).
rffloat0.0Risk-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
ParamTypeDefaultMeaning
muarray (n,)Expected returns per asset.
covarray (n, n)Covariance matrix.
risk_aversionfloat1.0Risk 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
ParamTypeDefaultMeaning
muarray (n_assets,)Expected returns per asset.
covarray (n_assets, n_assets)Covariance matrix.
nint50Number 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
efficient_frontier chart
The efficient frontier with each portfolio coloured by Sharpe ratio.

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
ParamTypeDefaultMeaning
weightsarray (n,)The portfolio weights.
covarray (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 asset

equal_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
ParamTypeDefaultMeaning
covarray (n, n)Covariance matrix.
itersint200Maximum fixed-point iterations.
tolfloat1e-8Convergence 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 equal

Diagnostics#

portfolio_vol#

Portfolio volatility for a weight vector — the square root of wᵀ Σ w.

portfolio_vol(weights, cov) -> float
  • weights — 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) -> float
  • weights — 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 / v

Views, 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
ParamTypeDefaultMeaning
w_mktarray (n,)Market-cap (equilibrium) weights.
covarray (n, n)Covariance matrix.
views_parray (k, n)Pick matrix — one row per view, selecting/weighting assets.
views_qarray (k,)The expected return of each view portfolio.
taufloat0.05Uncertainty scalar on the equilibrium prior.
deltafloat2.5Market risk-aversion used in reverse optimization.
omegaarray (k, k) | NoneNoneView-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
ParamTypeDefaultMeaning
corrarray (n, n)Correlation matrix to denoise.
n_obsintNumber 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 optimizers

michaud_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
ParamTypeDefaultMeaning
muarray (n,)Expected returns per asset.
covarray (n, n)Covariance matrix.
n_bootint200Number of bootstrap resamples.
long_onlyboolTrueClip each resampled solution to non-negative weights.
rngGenerator | NoneNonenumpy 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
ParamTypeDefaultMeaning
muarray (n,)Expected returns per asset.
covarray (n, n)Covariance matrix.
w_prevarray (n,)The weights you currently hold.
gamma_tcfloat1.0L1 transaction-cost penalty per unit of turnover.
risk_aversionfloat2.5Risk 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 for

See also#