Closed-form Markowitz mean-variance frontier.
The :class:AnalyticFrontier class encapsulates the Merton parametric
solution. All computations are derived from the four scalars
(A, B, C, D) (see :mod:markowitz.core.merton_scalars) and a
single Cholesky factorization of Sigma that produces the basis
vectors Sigma^-1 1 and Sigma^-1 mu.
Formulae used throughout:
-
Global minimum variance portfolio (GMV):
w_gmv = Sigma^-1 * 1 / A
var_gmv = 1 / A
mu_gmv = B / A
-
Tangency portfolio at risk-free rate rf:
w_tan = Sigma^-1 (mu - rf * 1) / (B - A * rf)
Sharpe = sqrt(C - 2 * B * rf + A * rf*2)
-
Efficient portfolio at target return mu_p:
w(mu_p) = lam1 * Sigma^-1 mu + lam2 * Sigma^-1 1
lam1 = (A * mu_p - B) / D
lam2 = (C - B * mu_p) / D
var(mu_p) = (A * mu_p**2 - 2 * B * mu_p + C) / D
AnalyticFrontier(mu: npt.ArrayLike, Sigma: npt.ArrayLike)
Closed-form mean-variance frontier for (mu, Sigma).
Construction is the only point at which Sigma is factored; all
subsequent queries (gmv, tangency, efficient_return,
frontier) are O(n) once the cached basis vectors are available.
Source code in src/markowitz/core/frontier.py
| def __init__(self, mu: npt.ArrayLike, Sigma: npt.ArrayLike) -> None:
mu_arr = np.asarray(mu, dtype=np.float64)
Sigma_arr = np.asarray(Sigma, dtype=np.float64)
if mu_arr.ndim != 1:
raise NumericalError(f"mu must be 1-D, got shape {mu_arr.shape!r}")
if Sigma_arr.ndim != 2 or Sigma_arr.shape[0] != Sigma_arr.shape[1]:
raise NumericalError(f"Sigma must be square 2-D, got shape {Sigma_arr.shape!r}")
if mu_arr.shape[0] != Sigma_arr.shape[0]:
raise NumericalError(
"Dimension mismatch between mu and Sigma: "
f"{mu_arr.shape[0]} vs {Sigma_arr.shape[0]}"
)
if not np.all(np.isfinite(mu_arr)):
raise NumericalError("mu contains non-finite values")
# Validate that Sigma is symmetric positive definite. This
# raises SingularCovarianceError on failure, matching the
# documented contract.
psd_check(Sigma_arr)
n = mu_arr.shape[0]
ones = np.ones(n, dtype=np.float64)
rhs = np.column_stack((ones, mu_arr))
sol = cholesky_solve(Sigma_arr, rhs)
Sinv_one = sol[:, 0]
Sinv_mu = sol[:, 1]
A = float(ones @ Sinv_one)
B = float(ones @ Sinv_mu)
C = float(mu_arr @ Sinv_mu)
D = A * C - B * B
self._mu = mu_arr
self._Sigma = Sigma_arr
self._Sinv_one = Sinv_one
self._Sinv_mu = Sinv_mu
self._A = A
self._B = B
self._C = C
self._D = D
self._n = int(mu_arr.shape[0])
|
abcd: MertonABCD
property
The Merton scalars associated with (mu, Sigma).
is_degenerate: bool
property
True when D is essentially zero.
A degenerate frontier collapses to a single point in mean-
variance space because mu is collinear with 1 (every
asset has the same expected return). Efficient-return queries
are still well-defined at the GMV expected return but are
infeasible for any other target.
n_assets: int
property
Number of assets in the investment universe.
efficient_return(mu_p: float) -> Portfolio
Return the minimum-variance portfolio with expected return mu_p.
Source code in src/markowitz/core/frontier.py
| def efficient_return(self, mu_p: float) -> Portfolio:
"""Return the minimum-variance portfolio with expected return ``mu_p``."""
from markowitz.core.portfolio import Portfolio # noqa: PLC0415 - circular-import guard
mu_target = float(mu_p)
if self.is_degenerate:
raise InfeasibleFrontierError(
"Frontier is degenerate (D ~ 0); efficient_return is "
"undefined for arbitrary target returns."
)
lam1 = (self._A * mu_target - self._B) / self._D
lam2 = (self._C - self._B * mu_target) / self._D
w = lam1 * self._Sinv_mu + lam2 * self._Sinv_one
var = (self._A * mu_target * mu_target - 2.0 * self._B * mu_target + self._C) / self._D
# Floor to zero to absorb sub-eps rounding at the GMV vertex.
var = max(var, 0.0)
return Portfolio(
weights=w,
expected_return=mu_target,
volatility=float(np.sqrt(var)),
sharpe=None,
)
|
frontier(n_points: int = 100, *, mu_min: float | None = None, mu_max: float | None = None) -> list[Portfolio]
Return n_points portfolios spanning [mu_min, mu_max].
Defaults straddle the GMV expected return symmetrically: if no
bounds are supplied, the frontier extends from mu_GMV to
mu_GMV + 2 * (max(mu) - mu_GMV). Callers that need a
specific range should pass it explicitly.
Source code in src/markowitz/core/frontier.py
| def frontier(
self,
n_points: int = 100,
*,
mu_min: float | None = None,
mu_max: float | None = None,
) -> list[Portfolio]:
"""Return ``n_points`` portfolios spanning ``[mu_min, mu_max]``.
Defaults straddle the GMV expected return symmetrically: if no
bounds are supplied, the frontier extends from ``mu_GMV`` to
``mu_GMV + 2 * (max(mu) - mu_GMV)``. Callers that need a
specific range should pass it explicitly.
"""
if n_points < 2:
raise ValueError(f"n_points must be >= 2, got {n_points}")
mu_gmv = self._B / self._A
if mu_min is None:
mu_min = float(mu_gmv)
if mu_max is None:
top = float(np.max(self._mu))
mu_max = float(mu_gmv + 2.0 * (top - mu_gmv))
if mu_max <= mu_min:
mu_max = mu_min + 1.0
if mu_max < mu_min:
raise ValueError(f"mu_max ({mu_max}) must be >= mu_min ({mu_min})")
grid = np.linspace(float(mu_min), float(mu_max), int(n_points))
return [self.efficient_return(float(m)) for m in grid]
|
gmv() -> Portfolio
Return the global minimum-variance portfolio.
Source code in src/markowitz/core/frontier.py
| def gmv(self) -> Portfolio:
"""Return the global minimum-variance portfolio."""
from markowitz.core.portfolio import Portfolio # noqa: PLC0415 - circular-import guard
w = self._Sinv_one / self._A
var = 1.0 / self._A
mu_p = self._B / self._A
return Portfolio(
weights=w,
expected_return=float(mu_p),
volatility=float(np.sqrt(var)),
sharpe=None,
)
|
tangency(rf: float) -> Portfolio
Return the tangency portfolio for risk-free rate rf.
Raises:
| Type |
Description |
InfeasibleFrontierError
|
If rf coincides with the GMV expected return (i.e.
B - A * rf is numerically zero), making the tangency
line parallel to the frontier.
|
Source code in src/markowitz/core/frontier.py
| def tangency(self, rf: float) -> Portfolio:
"""Return the tangency portfolio for risk-free rate ``rf``.
Raises
------
InfeasibleFrontierError
If ``rf`` coincides with the GMV expected return (i.e.
``B - A * rf`` is numerically zero), making the tangency
line parallel to the frontier.
"""
from markowitz.core.portfolio import Portfolio # noqa: PLC0415 - circular-import guard
rf_f = float(rf)
denom = self._B - self._A * rf_f
scale = max(abs(self._B), self._A * abs(rf_f), 1.0)
if abs(denom) < 1e-12 * scale:
raise InfeasibleFrontierError(
"Tangency portfolio is undefined: risk-free rate coincides "
f"with mu_GMV = {self._B / self._A:.10g} (B - A*rf = {denom:.3e})"
)
# w = Sigma^-1 (mu - rf * 1) / (B - A * rf)
# Reuse cached basis vectors instead of a second Cholesky solve.
w = (self._Sinv_mu - rf_f * self._Sinv_one) / denom
excess = self._C - 2.0 * self._B * rf_f + self._A * rf_f * rf_f
# Algebraically excess >= 0 for a PD Sigma, but floor it to guard
# against floating-point chatter near zero.
excess = max(excess, 0.0)
sharpe = float(np.sqrt(excess))
er = float(w @ self._mu)
var = float(w @ self._Sigma @ w)
vol = float(np.sqrt(max(var, 0.0)))
return Portfolio(
weights=w,
expected_return=er,
volatility=vol,
sharpe=sharpe,
)
|