Skip to content

optimization

Pure solver functions: (expected_returns, cov_matrix, constraints) -> weights. Every function here is importable directly, with no dependency on Portfolio - see construction for the discoverable, ModelResult-returning dispatcher over these (optimize/build), and estimators for computing expected_returns/cov_matrix.

solvers

Pure portfolio optimization solvers: (expected_returns, cov_matrix, constraints) -> weights.

Every function here is importable directly for functional/advanced use - none of them depend on Portfolio or portpy.models.estimators, you bring your own mu/Sigma. All of them (except hierarchical_risk_parity, which needs no numerical solver) go through models.base.solve_weights - multi-start SLSQP; see that module's docstring for why PortPy stays scipy-only here instead of adding a convex-QP dependency like cvxpy.

Nothing here checks that expected_returns and cov_matrix share a time scale. Every docstring below says "annualized" because that's what portpy.models.estimators produces by default and what rf/risk_aversion defaults assume, but the solvers themselves are scale-agnostic: pass a periodic mu next to an annualized Sigma (or vice versa) and you get a solvable, plausible-looking, and financially meaningless result - no exception, no warning. Get both inputs from portpy.models.estimators (or otherwise put both on the same footing yourself) before optimizing.

mean_variance

mean_variance(
    expected_returns: Series,
    cov_matrix: DataFrame,
    constraints: Sequence[Any] | None = None,
    risk_aversion: float = 1.0,
) -> pd.Series

Classic Markowitz utility maximization: maximize w'mu - 0.5*risk_aversion*w'Sigma*w.

Parameters:

Name Type Description Default
expected_returns Series

Annualized expected return per asset.

required
cov_matrix DataFrame

Annualized asset covariance matrix (its columns define asset order).

required
constraints Sequence[Any] | None

See portpy.models.base (WeightBounds, GroupCap, TurnoverCap, NetExposure/GrossExposure). Full investment (NetExposure(1.0)) is assumed unless overridden.

None
risk_aversion float

Higher values weight the variance penalty more heavily, pulling the solution toward lower risk (and lower return).

1.0

Returns:

Type Description
Series

Solved weights, indexed by cov_matrix's columns.

Source code in src/portpy/models/optimization/solvers.py
def mean_variance(
    expected_returns: pd.Series,
    cov_matrix: pd.DataFrame,
    constraints: Sequence[Any] | None = None,
    risk_aversion: float = 1.0,
) -> pd.Series:
    """
    Classic Markowitz utility maximization: `maximize w'mu - 0.5*risk_aversion*w'Sigma*w`.

    Args:
        expected_returns: Annualized expected return per asset.
        cov_matrix: Annualized asset covariance matrix (its columns define asset order).
        constraints: See `portpy.models.base` (`WeightBounds`, `GroupCap`,
            `TurnoverCap`, `NetExposure`/`GrossExposure`). Full investment
            (`NetExposure(1.0)`) is assumed unless overridden.
        risk_aversion: Higher values weight the variance penalty more heavily,
            pulling the solution toward lower risk (and lower return).

    Returns:
        Solved weights, indexed by `cov_matrix`'s columns.
    """
    names = _asset_names(cov_matrix)
    mu = _mu_array(expected_returns, names)
    cov = _cov_array(cov_matrix, names)

    def objective(w: np.ndarray) -> float:
        return float(-(w @ mu) + 0.5 * risk_aversion * (w @ cov @ w))

    def jac(w: np.ndarray) -> np.ndarray:
        return -mu + risk_aversion * (cov @ w)

    weights, diagnostics = solve_weights(objective, names, constraints, jac=jac)
    weights.attrs["solve_diagnostics"] = diagnostics
    return weights

min_variance

min_variance(
    cov_matrix: DataFrame,
    constraints: Sequence[Any] | None = None,
) -> pd.Series

Minimize portfolio variance: minimize w'Sigma*w. Needs no return forecast at all.

Parameters:

Name Type Description Default
cov_matrix DataFrame

Annualized asset covariance matrix.

required
constraints Sequence[Any] | None

See mean_variance.

None

Returns:

Type Description
Series

Solved weights, indexed by cov_matrix's columns.

Source code in src/portpy/models/optimization/solvers.py
def min_variance(cov_matrix: pd.DataFrame, constraints: Sequence[Any] | None = None) -> pd.Series:
    """
    Minimize portfolio variance: `minimize w'Sigma*w`. Needs no return forecast at all.

    Args:
        cov_matrix: Annualized asset covariance matrix.
        constraints: See `mean_variance`.

    Returns:
        Solved weights, indexed by `cov_matrix`'s columns.
    """
    names = _asset_names(cov_matrix)
    cov = _cov_array(cov_matrix, names)

    def objective(w: np.ndarray) -> float:
        return float(w @ cov @ w)

    def jac(w: np.ndarray) -> np.ndarray:
        return 2.0 * (cov @ w)

    weights, diagnostics = solve_weights(objective, names, constraints, jac=jac)
    weights.attrs["solve_diagnostics"] = diagnostics
    return weights

max_sharpe

max_sharpe(
    expected_returns: Series,
    cov_matrix: DataFrame,
    constraints: Sequence[Any] | None = None,
    rf: float = 0.0,
) -> pd.Series

Maximize the (annualized) Sharpe ratio: maximize (w'mu - rf) / sqrt(w'Sigma*w).

Parameters:

Name Type Description Default
expected_returns Series

Annualized expected return per asset.

required
cov_matrix DataFrame

Annualized asset covariance matrix.

required
constraints Sequence[Any] | None

See mean_variance.

None
rf float

Annual risk-free rate, in the same (annualized) units as expected_returns.

0.0

Returns:

Type Description
Series

Solved weights, indexed by cov_matrix's columns.

Source code in src/portpy/models/optimization/solvers.py
def max_sharpe(
    expected_returns: pd.Series,
    cov_matrix: pd.DataFrame,
    constraints: Sequence[Any] | None = None,
    rf: float = 0.0,
) -> pd.Series:
    """
    Maximize the (annualized) Sharpe ratio: `maximize (w'mu - rf) / sqrt(w'Sigma*w)`.

    Args:
        expected_returns: Annualized expected return per asset.
        cov_matrix: Annualized asset covariance matrix.
        constraints: See `mean_variance`.
        rf: Annual risk-free rate, in the same (annualized) units as `expected_returns`.

    Returns:
        Solved weights, indexed by `cov_matrix`'s columns.
    """
    names = _asset_names(cov_matrix)
    mu = _mu_array(expected_returns, names)
    cov = _cov_array(cov_matrix, names)

    def objective(w: np.ndarray) -> float:
        vol = np.sqrt(max(float(w @ cov @ w), 1e-16))
        return float(-(float(w @ mu) - rf) / vol)

    weights, diagnostics = solve_weights(objective, names, constraints)
    weights.attrs["solve_diagnostics"] = diagnostics
    return weights

target_return

target_return(
    expected_returns: Series,
    cov_matrix: DataFrame,
    target: float,
    constraints: Sequence[Any] | None = None,
) -> pd.Series

Minimize variance subject to hitting an exact expected return: w'mu == target.

Note this is an equality, not "at least target" - a target below the global minimum-variance portfolio's own return still pins you to that lower, needlessly higher-variance point (some other libraries, e.g. PyPortfolioOpt's efficient_return, use a >= inequality instead, which substitutes the min-variance portfolio once its own return already clears the bar). The equality is what lets efficient_frontier sweep a full, non-degenerate curve by calling this at every grid point.

Parameters:

Name Type Description Default
expected_returns Series

Annualized expected return per asset.

required
cov_matrix DataFrame

Annualized asset covariance matrix.

required
target float

Required annualized portfolio return.

required
constraints Sequence[Any] | None

See mean_variance.

None

Returns:

Type Description
Series

Solved weights, indexed by cov_matrix's columns. If target is

Series

infeasible given the other constraints, the SLSQP solve won't fully

Series

converge - check .models.optimize(...).diagnostics["success"].

Source code in src/portpy/models/optimization/solvers.py
def target_return(
    expected_returns: pd.Series,
    cov_matrix: pd.DataFrame,
    target: float,
    constraints: Sequence[Any] | None = None,
) -> pd.Series:
    """
    Minimize variance subject to hitting an exact expected return: `w'mu == target`.

    Note this is an equality, not "at least target" - a target below the
    global minimum-variance portfolio's own return still pins you to that
    lower, needlessly higher-variance point (some other libraries, e.g.
    PyPortfolioOpt's `efficient_return`, use a `>=` inequality instead, which
    substitutes the min-variance portfolio once its own return already clears
    the bar). The equality is what lets `efficient_frontier` sweep a full,
    non-degenerate curve by calling this at every grid point.

    Args:
        expected_returns: Annualized expected return per asset.
        cov_matrix: Annualized asset covariance matrix.
        target: Required annualized portfolio return.
        constraints: See `mean_variance`.

    Returns:
        Solved weights, indexed by `cov_matrix`'s columns. If `target` is
        infeasible given the other constraints, the SLSQP solve won't fully
        converge - check `.models.optimize(...).diagnostics["success"]`.
    """
    names = _asset_names(cov_matrix)
    mu = _mu_array(expected_returns, names)
    cov = _cov_array(cov_matrix, names)

    def objective(w: np.ndarray) -> float:
        return float(w @ cov @ w)

    def jac(w: np.ndarray) -> np.ndarray:
        return 2.0 * (cov @ w)

    return_constraint = {"type": "eq", "fun": lambda w: float(w @ mu - target)}
    weights, diagnostics = solve_weights(objective, names, constraints, jac=jac, extra_scipy_constraints=[return_constraint])
    weights.attrs["solve_diagnostics"] = diagnostics
    return weights

target_volatility

target_volatility(
    expected_returns: Series,
    cov_matrix: DataFrame,
    target: float,
    constraints: Sequence[Any] | None = None,
) -> pd.Series

Maximize expected return subject to a volatility cap: w'Sigma*w <= target^2.

Parameters:

Name Type Description Default
expected_returns Series

Annualized expected return per asset.

required
cov_matrix DataFrame

Annualized asset covariance matrix.

required
target float

Maximum allowed annualized volatility.

required
constraints Sequence[Any] | None

See mean_variance.

None

Returns:

Type Description
Series

Solved weights, indexed by cov_matrix's columns. The cap binds

Series

(volatility == target) whenever the unconstrained-by-risk optimum

Series

would exceed it, which is the usual case.

Source code in src/portpy/models/optimization/solvers.py
def target_volatility(
    expected_returns: pd.Series,
    cov_matrix: pd.DataFrame,
    target: float,
    constraints: Sequence[Any] | None = None,
) -> pd.Series:
    """
    Maximize expected return subject to a volatility cap: `w'Sigma*w <= target^2`.

    Args:
        expected_returns: Annualized expected return per asset.
        cov_matrix: Annualized asset covariance matrix.
        target: Maximum allowed annualized volatility.
        constraints: See `mean_variance`.

    Returns:
        Solved weights, indexed by `cov_matrix`'s columns. The cap binds
        (volatility == target) whenever the unconstrained-by-risk optimum
        would exceed it, which is the usual case.
    """
    names = _asset_names(cov_matrix)
    mu = _mu_array(expected_returns, names)
    cov = _cov_array(cov_matrix, names)

    def objective(w: np.ndarray) -> float:
        return float(-(w @ mu))

    def jac(w: np.ndarray) -> np.ndarray:
        return -mu

    vol_constraint = {"type": "ineq", "fun": lambda w: float(target**2 - w @ cov @ w)}
    weights, diagnostics = solve_weights(objective, names, constraints, jac=jac, extra_scipy_constraints=[vol_constraint])
    weights.attrs["solve_diagnostics"] = diagnostics
    return weights

efficient_frontier

efficient_frontier(
    expected_returns: Series,
    cov_matrix: DataFrame,
    n_points: int = 50,
    constraints: Sequence[Any] | None = None,
    min_return: float | None = None,
    max_return: float | None = None,
) -> pd.DataFrame

Trace the efficient frontier: the min-variance solution at each of n_points target returns spanning [min_return, max_return].

Parameters:

Name Type Description Default
expected_returns Series

Annualized expected return per asset.

required
cov_matrix DataFrame

Annualized asset covariance matrix.

required
n_points int

Number of frontier points to compute.

50
constraints Sequence[Any] | None

See mean_variance, applied at every point.

None
min_return float | None

Lower end of the return grid. Defaults to the global minimum-variance portfolio's own return.

None
max_return float | None

Upper end of the return grid. Defaults to the single highest per-asset expected return (only exactly reachable long-only/unconstrained; with custom bounds it may not be, in which case that point's row is simply dropped - see below). This default is a long-only ceiling: it does not account for leverage or short-selling. If constraints includes a WeightBounds with high > 1 or low < 0, the true reachable frontier extends past the single highest per-asset return - pass max_return explicitly to trace that far, since the default won't do it for you.

None

Returns:

Type Description
DataFrame

A DataFrame with one row per (successfully solved) grid point:

DataFrame

columns "target_return", "return", "volatility", "sharpe" (rf=0),

DataFrame

plus one column per asset holding that point's weight. Points where

DataFrame

target_return turned out infeasible for the given constraints

DataFrame

are silently dropped, so the result can have fewer than n_points rows.

Source code in src/portpy/models/optimization/solvers.py
def efficient_frontier(
    expected_returns: pd.Series,
    cov_matrix: pd.DataFrame,
    n_points: int = 50,
    constraints: Sequence[Any] | None = None,
    min_return: float | None = None,
    max_return: float | None = None,
) -> pd.DataFrame:
    """
    Trace the efficient frontier: the min-variance solution at each of `n_points`
    target returns spanning `[min_return, max_return]`.

    Args:
        expected_returns: Annualized expected return per asset.
        cov_matrix: Annualized asset covariance matrix.
        n_points: Number of frontier points to compute.
        constraints: See `mean_variance`, applied at every point.
        min_return: Lower end of the return grid. Defaults to the global
            minimum-variance portfolio's own return.
        max_return: Upper end of the return grid. Defaults to the single
            highest per-asset expected return (only exactly reachable
            long-only/unconstrained; with custom bounds it may not be, in
            which case that point's row is simply dropped - see below). This
            default is a long-only ceiling: it does not account for leverage
            or short-selling. If `constraints` includes a `WeightBounds` with
            `high > 1` or `low < 0`, the true reachable frontier extends past
            the single highest per-asset return - pass `max_return` explicitly
            to trace that far, since the default won't do it for you.

    Returns:
        A DataFrame with one row per (successfully solved) grid point:
        columns "target_return", "return", "volatility", "sharpe" (rf=0),
        plus one column per asset holding that point's weight. Points where
        `target_return` turned out infeasible for the given `constraints`
        are silently dropped, so the result can have fewer than `n_points` rows.
    """
    names = _asset_names(cov_matrix)
    mu = expected_returns.reindex(names)
    cov_np = _cov_array(cov_matrix, names)

    if min_return is None:
        mv_weights = min_variance(cov_matrix, constraints)
        min_return = float(mv_weights.to_numpy() @ mu.to_numpy())
    if max_return is None:
        max_return = float(mu.max())
    if max_return <= min_return:
        max_return = min_return + abs(min_return) * 0.5 + 1e-6

    rows = []
    for target in np.linspace(min_return, max_return, n_points):
        w = target_return(expected_returns, cov_matrix, target=float(target), constraints=constraints)
        w_np = w.reindex(names).to_numpy()
        port_ret = float(w_np @ mu.to_numpy())
        port_vol = float(np.sqrt(max(w_np @ cov_np @ w_np, 0.0)))
        row = {"target_return": float(target), "return": port_ret, "volatility": port_vol, "sharpe": safe_divide(port_ret, port_vol)}
        row.update(dict(zip(names, w_np, strict=True)))
        rows.append(row)

    return pd.DataFrame(rows)

black_litterman

black_litterman(
    cov_matrix: DataFrame,
    market_weights: Series,
    views: dict[str, float] | tuple[DataFrame, Series],
    tau: float = 0.05,
    risk_aversion: float = 2.5,
    omega: DataFrame | None = None,
    constraints: Sequence[Any] | None = None,
) -> pd.Series

Black-Litterman: blend market-implied equilibrium returns with explicit views, then mean-variance optimize the posterior estimates.

Parameters:

Name Type Description Default
cov_matrix DataFrame

Annualized asset covariance matrix.

required
market_weights Series

Market-capitalization (or other equilibrium) weights, used to back out the implied equilibrium return pi = risk_aversion * Sigma @ w_mkt.

required
views dict[str, float] | tuple[DataFrame, Series]

Either a simple absolute-view dict ({"AAPL": 0.12} means "I expect AAPL to return 12%/yr"), or an explicit (P, Q) pair for relative/multi-asset views - P a DataFrame (one row per view, columns = a subset of cov_matrix's assets, entries = that view's picking weights) and Q a Series of view returns, one per row of P. Every view value (dict values, or Q) must be annualized, on the same scale as cov_matrix (and therefore pi) - nothing checks this, so a periodic-scale view number silently distorts the posterior instead of raising.

required
tau float

Scales the uncertainty of the equilibrium prior (tau * Sigma). Smaller values trust the equilibrium more; larger values let views dominate faster.

0.05
risk_aversion float

Used both to back out pi from market_weights and as the final mean-variance step's risk aversion.

2.5
omega DataFrame | None

View uncertainty matrix. Defaults to He & Litterman's usual proportional choice, diag(P @ (tau*Sigma) @ P') - views implied by noisier picking rows get proportionally less confidence automatically.

None
constraints Sequence[Any] | None

See mean_variance, applied to the final posterior optimization.

None

Returns:

Type Description
Series

Solved weights, indexed by cov_matrix's columns.

Source code in src/portpy/models/optimization/solvers.py
def black_litterman(
    cov_matrix: pd.DataFrame,
    market_weights: pd.Series,
    views: dict[str, float] | tuple[pd.DataFrame, pd.Series],
    tau: float = 0.05,
    risk_aversion: float = 2.5,
    omega: pd.DataFrame | None = None,
    constraints: Sequence[Any] | None = None,
) -> pd.Series:
    """
    Black-Litterman: blend market-implied equilibrium returns with explicit
    views, then mean-variance optimize the posterior estimates.

    Args:
        cov_matrix: Annualized asset covariance matrix.
        market_weights: Market-capitalization (or other equilibrium) weights,
            used to back out the implied equilibrium return `pi = risk_aversion * Sigma @ w_mkt`.
        views: Either a simple absolute-view dict (`{"AAPL": 0.12}` means "I
            expect AAPL to return 12%/yr"), or an explicit `(P, Q)` pair for
            relative/multi-asset views - `P` a DataFrame (one row per view,
            columns = a subset of `cov_matrix`'s assets, entries = that view's
            picking weights) and `Q` a Series of view returns, one per row of `P`.
            Every view value (dict values, or `Q`) must be annualized, on the
            same scale as `cov_matrix` (and therefore `pi`) - nothing checks
            this, so a periodic-scale view number silently distorts the
            posterior instead of raising.
        tau: Scales the uncertainty of the equilibrium prior (`tau * Sigma`).
            Smaller values trust the equilibrium more; larger values let views
            dominate faster.
        risk_aversion: Used both to back out `pi` from `market_weights` and as
            the final mean-variance step's risk aversion.
        omega: View uncertainty matrix. Defaults to He & Litterman's usual
            proportional choice, `diag(P @ (tau*Sigma) @ P')` - views implied
            by noisier picking rows get proportionally less confidence automatically.
        constraints: See `mean_variance`, applied to the final posterior optimization.

    Returns:
        Solved weights, indexed by `cov_matrix`'s columns.
    """
    names = _asset_names(cov_matrix)
    cov = _cov_array(cov_matrix, names)
    w_mkt = market_weights.reindex(names).fillna(0.0).to_numpy()

    pi = risk_aversion * (cov @ w_mkt)

    if isinstance(views, dict):
        assets = list(views.keys())
        index = {name: i for i, name in enumerate(names)}
        unknown = [a for a in assets if a not in index]
        if unknown:
            raise ValueError(f"views references unknown asset(s): {unknown}")
        p_matrix = np.zeros((len(assets), len(names)))
        for row, asset in enumerate(assets):
            p_matrix[row, index[asset]] = 1.0
        q_vector = np.array([views[a] for a in assets], dtype=float)
    else:
        p_df, q_series = views
        p_matrix = p_df.reindex(columns=names).fillna(0.0).to_numpy()
        q_vector = q_series.to_numpy(dtype=float)

    tau_cov = tau * cov
    if omega is None:
        omega_matrix = np.diag(np.diag(p_matrix @ tau_cov @ p_matrix.T))
        if np.allclose(omega_matrix, 0.0):
            omega_matrix = omega_matrix + np.eye(omega_matrix.shape[0]) * 1e-8
    else:
        omega_matrix = omega.to_numpy() if isinstance(omega, pd.DataFrame) else np.asarray(omega, dtype=float)

    tau_cov_inv = np.linalg.pinv(tau_cov)
    omega_inv = np.linalg.pinv(omega_matrix)
    posterior_precision = tau_cov_inv + p_matrix.T @ omega_inv @ p_matrix
    posterior_cov_of_mean = np.linalg.pinv(posterior_precision)
    posterior_mu = posterior_cov_of_mean @ (tau_cov_inv @ pi + p_matrix.T @ omega_inv @ q_vector)

    # Deliberately optimize on the ORIGINAL cov, not cov + posterior_cov_of_mean:
    # this is what makes "no views" reproduce market_weights exactly at the same
    # risk_aversion used to build pi (posterior_mu collapses to pi, and pi was
    # built from cov, not an inflated posterior). Some expositions add
    # posterior_cov_of_mean back in for the final step (folding return-estimation
    # uncertainty into the risk term); PortPy follows the more common convention
    # (matching PyPortfolioOpt's default) that keeps this recovery property intact.
    mu_series = pd.Series(posterior_mu, index=names)
    cov_df = pd.DataFrame(cov, index=names, columns=names)
    return mean_variance(mu_series, cov_df, constraints=constraints, risk_aversion=risk_aversion)

hierarchical_risk_parity

hierarchical_risk_parity(
    cov_matrix: DataFrame,
    constraints: Sequence[Any] | None = None,
    linkage_method: str = "single",
) -> pd.Series

Hierarchical Risk Parity (Lopez de Prado, 2016): cluster assets by correlation, then recursively split inverse-variance weight down the tree instead of inverting the covariance matrix directly.

No numerical optimizer is involved - HRP is a deterministic, closed-form algorithm, which is also why it tolerates a poorly-conditioned (e.g. near-singular) covariance matrix far better than the matrix-inversion-based solvers above.

Parameters:

Name Type Description Default
cov_matrix DataFrame

Asset covariance matrix (annualization doesn't affect the result - HRP only uses correlations and relative variances).

required
constraints Sequence[Any] | None

Only WeightBounds is supported (applied post-hoc: clip to bounds, then redistribute any surplus/deficit through the assets not already pinned at a bound, so the result still sums to 1 without re-violating the bound). Any other constraint type is ignored with a UserWarning, since recursive bisection has no natural way to target an exact group cap, turnover budget, or leverage level.

None
linkage_method str

scipy.cluster.hierarchy.linkage method (default "single", matching the original paper).

'single'

Returns:

Type Description
Series

Weights, indexed by cov_matrix's columns, long-only and summing to 1.

Source code in src/portpy/models/optimization/solvers.py
def hierarchical_risk_parity(
    cov_matrix: pd.DataFrame,
    constraints: Sequence[Any] | None = None,
    linkage_method: str = "single",
) -> pd.Series:
    """
    Hierarchical Risk Parity (Lopez de Prado, 2016): cluster assets by
    correlation, then recursively split inverse-variance weight down the tree
    instead of inverting the covariance matrix directly.

    No numerical optimizer is involved - HRP is a deterministic, closed-form
    algorithm, which is also why it tolerates a poorly-conditioned (e.g.
    near-singular) covariance matrix far better than the matrix-inversion-based
    solvers above.

    Args:
        cov_matrix: Asset covariance matrix (annualization doesn't affect the
            result - HRP only uses correlations and *relative* variances).
        constraints: Only `WeightBounds` is supported (applied post-hoc: clip
            to bounds, then redistribute any surplus/deficit through the
            assets not already pinned at a bound, so the result still sums to
            1 without re-violating the bound). Any other constraint type is
            ignored with a `UserWarning`, since recursive bisection has no
            natural way to target an exact group cap, turnover budget, or
            leverage level.
        linkage_method: `scipy.cluster.hierarchy.linkage` method (default
            "single", matching the original paper).

    Returns:
        Weights, indexed by `cov_matrix`'s columns, long-only and summing to 1.
    """
    names = _asset_names(cov_matrix)
    cov_np = _cov_array(cov_matrix, names)
    n = len(names)

    std = np.sqrt(np.diag(cov_np))
    corr = cov_np / np.outer(std, std)
    corr = np.clip(corr, -1.0, 1.0)
    np.fill_diagonal(corr, 1.0)

    if n > 2:
        dist = np.sqrt(np.clip(0.5 * (1.0 - corr), 0.0, None))
        link = hierarchy.linkage(squareform(dist, checks=False), method=linkage_method)
        sort_ix = hierarchy.leaves_list(link).tolist()
    else:
        sort_ix = list(range(n))

    def cluster_variance(idxs: list[int]) -> float:
        sub = cov_np[np.ix_(idxs, idxs)]
        inv_diag = 1.0 / np.diag(sub)
        ivp = inv_diag / inv_diag.sum()
        return float(ivp @ sub @ ivp)

    w = pd.Series(1.0, index=sort_ix)
    clusters = [sort_ix]
    while clusters:
        clusters = [c[j:k] for c in clusters for j, k in ((0, len(c) // 2), (len(c) // 2, len(c))) if len(c) > 1]
        for i in range(0, len(clusters), 2):
            left, right = clusters[i], clusters[i + 1]
            var_left = cluster_variance(left)
            var_right = cluster_variance(right)
            alpha = 1.0 - safe_divide(var_left, var_left + var_right)
            w[left] *= alpha
            w[right] *= 1.0 - alpha

    weights = w.sort_index()
    weights.index = names
    weights = weights.rename("weight")

    all_constraints = list(constraints or [])
    bounds_constraint = next((c for c in all_constraints if isinstance(c, WeightBounds)), None)
    unsupported = [c for c in all_constraints if not isinstance(c, WeightBounds)]
    if unsupported:
        warnings.warn(
            "hierarchical_risk_parity only supports WeightBounds; ignoring "
            f"{[type(c).__name__ for c in unsupported]} - recursive bisection has no "
            "natural way to enforce group/turnover/exposure constraints.",
            stacklevel=2,
        )
    if bounds_constraint is not None:
        weights = _clip_and_redistribute(weights, bounds_constraint)

    return weights

risk_parity

risk_parity(
    cov_matrix: DataFrame,
    constraints: Sequence[Any] | None = None,
) -> pd.Series

Equal Risk Contribution: every asset contributes the same share of total portfolio risk. A special case of risk_budgeting with an equal budget.

Parameters:

Name Type Description Default
cov_matrix DataFrame

Annualized asset covariance matrix.

required
constraints Sequence[Any] | None

See risk_budgeting.

None

Returns:

Type Description
Series

Solved weights, indexed by cov_matrix's columns.

Source code in src/portpy/models/optimization/solvers.py
def risk_parity(cov_matrix: pd.DataFrame, constraints: Sequence[Any] | None = None) -> pd.Series:
    """
    Equal Risk Contribution: every asset contributes the same share of total
    portfolio risk. A special case of `risk_budgeting` with an equal budget.

    Args:
        cov_matrix: Annualized asset covariance matrix.
        constraints: See `risk_budgeting`.

    Returns:
        Solved weights, indexed by `cov_matrix`'s columns.
    """
    names = _asset_names(cov_matrix)
    return risk_budgeting(cov_matrix, pd.Series(1.0, index=names), constraints=constraints)

risk_budgeting

risk_budgeting(
    cov_matrix: DataFrame,
    budget: Series,
    constraints: Sequence[Any] | None = None,
) -> pd.Series

Solve for weights whose risk contributions match a target budget: weight_i * MCTR_i approx= budget_i * portfolio_variance for every asset.

Parameters:

Name Type Description Default
cov_matrix DataFrame

Annualized asset covariance matrix.

required
budget Series

Target risk-contribution share per asset (renormalized to sum to 1 internally, so relative sizes are what matter).

required
constraints Sequence[Any] | None

See mean_variance. Risk budgeting is only well-posed long-only - overriding WeightBounds to allow negative weights makes "risk contribution" sign-ambiguous.

None

Returns:

Type Description
Series

Solved weights, indexed by cov_matrix's columns.

Source code in src/portpy/models/optimization/solvers.py
def risk_budgeting(
    cov_matrix: pd.DataFrame,
    budget: pd.Series,
    constraints: Sequence[Any] | None = None,
) -> pd.Series:
    """
    Solve for weights whose risk contributions match a target budget:
    `weight_i * MCTR_i approx= budget_i * portfolio_variance` for every asset.

    Args:
        cov_matrix: Annualized asset covariance matrix.
        budget: Target risk-contribution share per asset (renormalized to sum
            to 1 internally, so relative sizes are what matter).
        constraints: See `mean_variance`. Risk budgeting is only well-posed
            long-only - overriding `WeightBounds` to allow negative weights
            makes "risk contribution" sign-ambiguous.

    Returns:
        Solved weights, indexed by `cov_matrix`'s columns.
    """
    names = _asset_names(cov_matrix)
    cov = _cov_array(cov_matrix, names)
    b = budget.reindex(names).to_numpy(dtype=float)
    b = b / b.sum()

    def objective(w: np.ndarray) -> float:
        port_var = float(w @ cov @ w)
        if port_var <= 1e-16:
            return 0.0
        risk_contribution = w * (cov @ w)
        target = b * port_var
        return float(np.sum((risk_contribution - target) ** 2))

    weights, diagnostics = solve_weights(objective, names, constraints)
    weights.attrs["solve_diagnostics"] = diagnostics
    return weights

maximum_diversification

maximum_diversification(
    cov_matrix: DataFrame,
    constraints: Sequence[Any] | None = None,
) -> pd.Series

Maximize metrics.covariance.diversification_ratio directly: maximize (w'sigma) / sqrt(w'Sigma*w).

Parameters:

Name Type Description Default
cov_matrix DataFrame

Annualized asset covariance matrix.

required
constraints Sequence[Any] | None

See mean_variance.

None

Returns:

Type Description
Series

Solved weights, indexed by cov_matrix's columns.

Source code in src/portpy/models/optimization/solvers.py
def maximum_diversification(cov_matrix: pd.DataFrame, constraints: Sequence[Any] | None = None) -> pd.Series:
    """
    Maximize `metrics.covariance.diversification_ratio` directly: `maximize (w'sigma) / sqrt(w'Sigma*w)`.

    Args:
        cov_matrix: Annualized asset covariance matrix.
        constraints: See `mean_variance`.

    Returns:
        Solved weights, indexed by `cov_matrix`'s columns.
    """
    names = _asset_names(cov_matrix)

    def objective(w: np.ndarray) -> float:
        dr = _diversification_ratio(pd.Series(w, index=names), cov_matrix)
        return float(-dr) if np.isfinite(dr) else 0.0

    weights, diagnostics = solve_weights(objective, names, constraints)
    weights.attrs["solve_diagnostics"] = diagnostics
    return weights