Skip to content

construction

Constraint objects (re-exported from portpy.models.base), plus the optimize/build/efficient_frontier recipe layer over portpy.models.optimization. optimize/build/efficient_frontier are also exposed directly on Portfolio.models for convenience - see Construction, optimization & management.

Constraint objects

constraints

Constraint objects for portpy.models.optimization/.construction.

Re-exported from portpy.models.base, which is where they're actually defined (alongside the plumbing that translates them into scipy's constraint format) - this module exists so they're discoverable at the "recipe layer" a typical user reaches for: portpy.models.construction.constraints.WeightBounds(...).

GrossExposure dataclass

GrossExposure(target: float = 1.0)

sum(abs(w)) == target - caps total (long + short) exposure, the natural constraint for a long/short book (e.g. a 130/30 fund uses target=1.6).

GroupCap dataclass

GroupCap(
    groups: dict[str, list[str]],
    max_weight: float | dict[str, float],
    min_weight: float | dict[str, float] | None = None,
)

Caps (and optionally floors) total exposure to named groups of assets - e.g. sectors or core.asset.AssetClass buckets.

Parameters:

Name Type Description Default
groups dict[str, list[str]]

{group_name: [asset_names]}. Assets not listed in any group are unconstrained by this object.

required
max_weight float | dict[str, float]

Maximum summed weight per group - either one number applied to every group, or a {group_name: cap} dict.

required
min_weight float | dict[str, float] | None

Optional minimum summed weight per group, same shape as max_weight.

None

NetExposure dataclass

NetExposure(target: float = 1.0)

sum(w) == target - the usual full-investment constraint (target=1.0).

Applied automatically with target=1.0 when a constraint list has neither this nor GrossExposure, so every solver is fully-invested by default.

TurnoverCap dataclass

TurnoverCap(
    max_turnover: float,
    current_weights: Series | None = None,
)

Caps how far the solved weights may move away from a starting weight vector.

Parameters:

Name Type Description Default
max_turnover float

Maximum allowed sum(abs(w - current_weights)) - note this is the raw sum, NOT divided by 2. That makes it twice metrics.costs.turnover_from_weights's "one-way turnover" convention (which halves the same sum, so that selling 10% of A to buy 10% of B reads as "10% of the book traded" rather than "20%") - the two conventions are both individually well-defined but not interchangeable: a TurnoverCap(max_turnover=0.10) allows exactly the same trades as a one-way-turnover budget of 5%, not 10%.

required
current_weights Series | None

Starting weights. When left None, .models callers auto-fill it from the portfolio's current weights; calling the optimization functions directly requires setting it explicitly.

None

WeightBounds dataclass

WeightBounds(
    low: float = 0.0,
    high: float = 1.0,
    per_asset: dict[str, tuple[float, float]] | None = None,
)

Per-asset weight bounds. low/high apply to every asset unless overridden in per_asset.

Parameters:

Name Type Description Default
low float

Minimum weight for any asset not listed in per_asset (default 0.0, i.e. long-only). Pass a negative value to allow shorting.

0.0
high float

Maximum weight for any asset not listed in per_asset.

1.0
per_asset dict[str, tuple[float, float]] | None

Optional {asset_name: (low, high)} overrides for specific assets.

None

optimize / build / efficient_frontier

builders

The recipe layer users hit most often: dispatch to a named optimizer (optimize), or estimate expected_returns/cov_matrix from raw returns first and then dispatch (build) - both wrapped into a ModelResult. efficient_frontier lives here too (not in optimize's dispatch table - see its own docstring) since it needs the same ModelResult-wrapping treatment.

optimize

optimize(
    expected_returns: Series | None = None,
    cov_matrix: DataFrame | None = None,
    method: str = "mean_variance",
    constraints: Sequence[Any] | None = None,
    **kwargs: Any,
) -> ModelResult

Dispatch to a named solver in portpy.models.optimization, wrapping its result into a ModelResult with solver diagnostics attached.

Parameters:

Name Type Description Default
expected_returns Series | None

Annualized expected return per asset. Required by "mean_variance", "max_sharpe", "target_return", and "target_volatility"; ignored (and not required) by every other method.

None
cov_matrix DataFrame | None

Annualized asset covariance matrix. Required by every method.

None
method str

One of "mean_variance", "max_sharpe", "min_variance", "target_return", "target_volatility", "black_litterman", "hierarchical_risk_parity", "risk_parity", "risk_budgeting", "maximum_diversification".

'mean_variance'
constraints Sequence[Any] | None

Forwarded to the underlying solver - see portpy.models.base.

None
**kwargs Any

Method-specific keyword arguments, e.g. risk_aversion= for "mean_variance"/"black_litterman", target= for "target_return"/ "target_volatility", rf= for "max_sharpe", market_weights=/views=/ tau=/omega= for "black_litterman", budget= for "risk_budgeting", linkage_method= for "hierarchical_risk_parity".

{}

Returns:

Type Description
ModelResult

A ModelResult named after method. .diagnostics always has

ModelResult

"method", plus (for every method except hierarchical_risk_parity,

ModelResult

which isn't numerically solved) "success", "objective_value",

ModelResult

"iterations", "message", and "n_restarts" from the solve.

Raises:

Type Description
ValueError

If method is unrecognized, cov_matrix is missing, or expected_returns is missing for a method that requires it.

Source code in src/portpy/models/construction/builders.py
def optimize(
    expected_returns: pd.Series | None = None,
    cov_matrix: pd.DataFrame | None = None,
    method: str = "mean_variance",
    constraints: Sequence[Any] | None = None,
    **kwargs: Any,
) -> ModelResult:
    """
    Dispatch to a named solver in `portpy.models.optimization`, wrapping its
    result into a `ModelResult` with solver diagnostics attached.

    Args:
        expected_returns: Annualized expected return per asset. Required by
            "mean_variance", "max_sharpe", "target_return", and "target_volatility";
            ignored (and not required) by every other method.
        cov_matrix: Annualized asset covariance matrix. Required by every method.
        method: One of "mean_variance", "max_sharpe", "min_variance",
            "target_return", "target_volatility", "black_litterman",
            "hierarchical_risk_parity", "risk_parity", "risk_budgeting",
            "maximum_diversification".
        constraints: Forwarded to the underlying solver - see `portpy.models.base`.
        **kwargs: Method-specific keyword arguments, e.g. `risk_aversion=` for
            "mean_variance"/"black_litterman", `target=` for "target_return"/
            "target_volatility", `rf=` for "max_sharpe", `market_weights=`/`views=`/
            `tau=`/`omega=` for "black_litterman", `budget=` for "risk_budgeting",
            `linkage_method=` for "hierarchical_risk_parity".

    Returns:
        A `ModelResult` named after `method`. `.diagnostics` always has
        `"method"`, plus (for every method except `hierarchical_risk_parity`,
        which isn't numerically solved) `"success"`, `"objective_value"`,
        `"iterations"`, `"message"`, and `"n_restarts"` from the solve.

    Raises:
        ValueError: If `method` is unrecognized, `cov_matrix` is missing, or
            `expected_returns` is missing for a method that requires it.
    """
    if method not in _SOLVERS:
        raise ValueError(f"method must be one of {sorted(_SOLVERS)}, got {method!r}.")
    if cov_matrix is None:
        raise ValueError("cov_matrix is required.")
    if method in _NEEDS_EXPECTED_RETURNS and expected_returns is None:
        raise ValueError(f"method={method!r} requires expected_returns.")

    solver = _SOLVERS[method]
    params = signature(solver).parameters

    call_kwargs: dict[str, Any] = dict(kwargs)
    if "expected_returns" in params:
        call_kwargs.setdefault("expected_returns", expected_returns)
    if "cov_matrix" in params:
        call_kwargs.setdefault("cov_matrix", cov_matrix)
    if "constraints" in params:
        call_kwargs.setdefault("constraints", constraints)

    weights = solver(**call_kwargs)

    diagnostics: dict[str, Any] = {"method": method}
    diagnostics.update(getattr(weights, "attrs", {}).get("solve_diagnostics", {}))

    return ModelResult(name=method, weights=weights, diagnostics=diagnostics, meta={"constraints": list(constraints or [])})

build

build(
    y: DataFrame | None = None,
    method: str = "mean_variance",
    constraints: Sequence[Any] | None = None,
    expected_returns_kwargs: dict[str, Any] | None = None,
    covariance_kwargs: dict[str, Any] | None = None,
    **kwargs: Any,
) -> ModelResult

The recipe most users actually want: estimate expected_returns/cov_matrix from raw returns via portpy.models.estimators, then optimize().

Parameters:

Name Type Description Default
y DataFrame | None

Per-asset periodic returns (the full multi-asset DataFrame). Required explicitly here; auto-filled from the portfolio when called via portfolio.models.build(...).

None
method str

See optimize.

'mean_variance'
constraints Sequence[Any] | None

See optimize.

None
expected_returns_kwargs dict[str, Any] | None

Extra keyword arguments for estimators.expected_returns (e.g. {"method": "james_stein"}). Skipped entirely for methods that don't need expected_returns (see optimize), so this can be left as {}/None for those.

None
covariance_kwargs dict[str, Any] | None

Extra keyword arguments for estimators.covariance (e.g. {"method": "ledoit_wolf"}).

None
**kwargs Any

Forwarded to optimize (and from there to the chosen solver).

{}

Returns:

Type Description
ModelResult

A ModelResult exactly as optimize returns, with .meta["built_from"]

ModelResult

additionally recording the estimator kwargs and data shape used.

Raises:

Type Description
ValueError

If y is missing.

Source code in src/portpy/models/construction/builders.py
def build(
    y: pd.DataFrame | None = None,
    method: str = "mean_variance",
    constraints: Sequence[Any] | None = None,
    expected_returns_kwargs: dict[str, Any] | None = None,
    covariance_kwargs: dict[str, Any] | None = None,
    **kwargs: Any,
) -> ModelResult:
    """
    The recipe most users actually want: estimate `expected_returns`/`cov_matrix`
    from raw returns via `portpy.models.estimators`, then `optimize()`.

    Args:
        y: Per-asset periodic returns (the full multi-asset DataFrame). Required
            explicitly here; auto-filled from the portfolio when called via
            `portfolio.models.build(...)`.
        method: See `optimize`.
        constraints: See `optimize`.
        expected_returns_kwargs: Extra keyword arguments for
            `estimators.expected_returns` (e.g. `{"method": "james_stein"}`).
            Skipped entirely for methods that don't need expected_returns
            (see `optimize`), so this can be left as `{}`/`None` for those.
        covariance_kwargs: Extra keyword arguments for `estimators.covariance`
            (e.g. `{"method": "ledoit_wolf"}`).
        **kwargs: Forwarded to `optimize` (and from there to the chosen solver).

    Returns:
        A `ModelResult` exactly as `optimize` returns, with `.meta["built_from"]`
        additionally recording the estimator kwargs and data shape used.

    Raises:
        ValueError: If `y` is missing.
    """
    if y is None:
        raise ValueError(
            "y (the multi-asset return DataFrame) is required - pass it explicitly, "
            "or call via portfolio.models.build(...) so it's auto-filled."
        )

    er_kwargs = dict(expected_returns_kwargs or {})
    cov_kwargs = dict(covariance_kwargs or {})

    mu = _estimate_expected_returns(y, **er_kwargs) if method in _NEEDS_EXPECTED_RETURNS else None
    cov = _estimate_covariance(y, **cov_kwargs)

    result = optimize(expected_returns=mu, cov_matrix=cov, method=method, constraints=constraints, **kwargs)
    result.meta["built_from"] = {
        "expected_returns_kwargs": er_kwargs,
        "covariance_kwargs": cov_kwargs,
        "n_assets": cov.shape[0],
        "n_observations": len(y),
    }
    return result

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,
) -> ModelResult

Trace the efficient frontier and wrap it into a ModelResult - kept as its own call rather than folded into optimize()/build(), since it returns a curve of portfolios, not one.

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

Forwarded to every point's solve - see portpy.models.base.

None
min_return float | None

See portpy.models.optimization.efficient_frontier.

None
max_return float | None

See portpy.models.optimization.efficient_frontier.

None

Returns:

Type Description
ModelResult

A ModelResult named "efficient_frontier". .weights is the max-Sharpe

ModelResult

point on the curve; the full curve is .diagnostics["frontier"] (also

ModelResult

what .summary() returns for this result).

Raises:

Type Description
RuntimeError

If not even one grid point was solvable.

Source code in src/portpy/models/construction/builders.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,
) -> ModelResult:
    """
    Trace the efficient frontier and wrap it into a `ModelResult` - kept as its
    own call rather than folded into `optimize()`/`build()`, since it returns a
    curve of portfolios, not one.

    Args:
        expected_returns: Annualized expected return per asset.
        cov_matrix: Annualized asset covariance matrix.
        n_points: Number of frontier points to compute.
        constraints: Forwarded to every point's solve - see `portpy.models.base`.
        min_return: See `portpy.models.optimization.efficient_frontier`.
        max_return: See `portpy.models.optimization.efficient_frontier`.

    Returns:
        A `ModelResult` named "efficient_frontier". `.weights` is the max-Sharpe
        point on the curve; the full curve is `.diagnostics["frontier"]` (also
        what `.summary()` returns for this result).

    Raises:
        RuntimeError: If not even one grid point was solvable.
    """
    frontier = _opt.efficient_frontier(
        expected_returns, cov_matrix, n_points=n_points, constraints=constraints, min_return=min_return, max_return=max_return
    )
    if frontier.empty:
        raise RuntimeError("efficient_frontier: no grid point was solvable - check that constraints leave a feasible region.")

    asset_names = list(cov_matrix.columns)
    best_idx = frontier["sharpe"].idxmax()
    max_sharpe_weights = pd.Series(
        frontier.loc[best_idx, asset_names].to_numpy(dtype=float), index=asset_names, name="weight"
    )
    diagnostics = {
        "frontier": frontier,
        "n_points_requested": n_points,
        "n_points_solved": len(frontier),
        "max_sharpe_index": int(best_idx),
    }
    return ModelResult(
        name="efficient_frontier", weights=max_sharpe_weights, diagnostics=diagnostics, meta={"constraints": list(constraints or [])}
    )