Skip to content

base

Shared plumbing every other portpy.models submodule builds on: the ModelResult type every builder/optimizer/rebalancer returns, the constraint objects .optimization solvers understand, and the multi-start SLSQP solve routine (solve_weights) they're all built on. See Construction, optimization & management for how these fit together.

ModelResult

ModelResult dataclass

ModelResult(
    name: str,
    weights: Series,
    diagnostics: dict[str, Any] = dict(),
    meta: dict[str, Any] = dict(),
)

The structured result of any .models builder/optimizer/rebalancer call - the model-layer counterpart to :class:~portpy.explain.MetricResult.

Behaves like a plain container (not a float): the number you usually want is a whole weight vector, not a scalar. Explains itself the same way MetricResult does, via the _portpy_explain_name/_portpy_explain_value hook in portpy.explain.explain - _portpy_explain_value is set to the whole result (not a bare float), since a model's interpret reads weights/diagnostics, not a single number.

Attributes:

Name Type Description
name str

Registry key this result explains itself with (e.g. "mean_variance").

weights Series

Solved (or otherwise resulting) allocation, indexed by asset. For efficient_frontier, this is the max-Sharpe point on the curve - see diagnostics["frontier"] for the full curve.

diagnostics dict[str, Any]

Solver/estimation internals - status, objective value, iterations, inputs used. Shape varies by model; see each model's registered Explanation for what to expect.

meta dict[str, Any]

Free-form extra context (e.g. the method/constraints used to build this).

explain

explain(print_it: bool = True) -> str

Print (by default) and return the full explanation for this result's model type.

Parameters:

Name Type Description Default
print_it bool

If True (default), also print() the rendered text.

True

Returns:

Type Description
str

The rendered explanation text.

Source code in src/portpy/models/base.py
def explain(self, print_it: bool = True) -> str:
    """
    Print (by default) and return the full explanation for this result's model type.

    Args:
        print_it: If True (default), also print() the rendered text.

    Returns:
        The rendered explanation text.
    """
    from portpy.explain import explain as _explain

    return _explain(self, print_it=print_it)

summary

summary() -> pd.DataFrame

A tabular view: weights sorted by absolute size (or, for efficient_frontier results, the full return/volatility/weights curve).

Returns:

Type Description
DataFrame

A DataFrame carrying .attrs["portpy_explanation"] = self.name, so

DataFrame

portpy.explain() also works on the returned table.

Source code in src/portpy/models/base.py
def summary(self) -> pd.DataFrame:
    """
    A tabular view: weights sorted by absolute size (or, for `efficient_frontier`
    results, the full return/volatility/weights curve).

    Returns:
        A DataFrame carrying `.attrs["portpy_explanation"] = self.name`, so
        `portpy.explain()` also works on the returned table.
    """
    if "frontier" in self.diagnostics:
        frontier = self.diagnostics["frontier"].copy()
        frontier.attrs["portpy_explanation"] = self.name
        return frontier

    df = self.weights.rename("weight").to_frame()
    df = df.reindex(df["weight"].abs().sort_values(ascending=False).index)
    df.attrs["portpy_explanation"] = self.name
    return df

plot

plot(**kwargs: Any)

Render this result via portpy.visualization (not yet implemented).

Raises:

Type Description
NotImplementedError

Always, until Stage 2 (.visualization) ships.

Source code in src/portpy/models/base.py
def plot(self, **kwargs: Any):
    """
    Render this result via `portpy.visualization` (not yet implemented).

    Raises:
        NotImplementedError: Always, until Stage 2 (`.visualization`) ships.
    """
    raise NotImplementedError(
        "ModelResult.plot() delegates to portpy.visualization, which hasn't "
        "shipped yet (it's next, right after .models). Use .summary() or "
        ".explain() in the meantime."
    )

compare

compare(
    other: ModelResult | Series, **kwargs: Any
) -> ModelResult

Compare this result's weights against another result/portfolio.

Parameters:

Name Type Description Default
other ModelResult | Series

Another ModelResult, a Portfolio, or a raw weight Series.

required
**kwargs Any

Forwarded to portpy.models.management.compare.

{}

Returns:

Type Description
ModelResult

A ModelResult named "compare" - see models.management.compare.

Source code in src/portpy/models/base.py
def compare(self, other: ModelResult | pd.Series, **kwargs: Any) -> ModelResult:
    """
    Compare this result's weights against another result/portfolio.

    Args:
        other: Another `ModelResult`, a `Portfolio`, or a raw weight `Series`.
        **kwargs: Forwarded to `portpy.models.management.compare`.

    Returns:
        A `ModelResult` named "compare" - see `models.management.compare`.
    """
    from portpy.models.management.rebalancing import compare as _compare

    return _compare(self, other, **kwargs)

Constraint objects

base

Shared plumbing for :mod:portpy.models: the result type every builder/optimizer/ rebalancer returns, the constraint objects solvers understand, and the numerical solve routine (multi-start SLSQP) every function in :mod:portpy.models.optimization is built on.

PortPy's models extra deliberately stays scipy-only (no cvxpy/cvxopt) - every solver here is a general nonlinear program handed to scipy.optimize.minimize (SLSQP), not a specialized convex QP/SOCP solve. That trades a little numerical polish on the classic convex cases (mean-variance, min-variance) for one solver path that also handles the genuinely non-convex ones (max_sharpe's ratio objective, maximum_diversification) and arbitrary constraint combinations without a second code path.

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

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

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

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.

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).

Solver plumbing

solve_weights

solve_weights(
    objective: Callable[[ndarray], float],
    asset_names: Sequence[str],
    constraints: Sequence[Any] | None = None,
    jac: Callable[[ndarray], ndarray] | None = None,
    n_restarts: int = 4,
    seed: int = 7,
    maxiter: int = 1000,
    extra_scipy_constraints: Sequence[dict[str, Any]]
    | None = None,
) -> tuple[pd.Series, dict[str, Any]]

Minimize objective(w) over portfolio weights via multi-start SLSQP.

A full-investment constraint (NetExposure(1.0)) is applied automatically if constraints contains neither NetExposure nor GrossExposure. SLSQP's result is sensitive to its starting point, so this tries the equal-weight portfolio plus n_restarts - 1 random feasible-ish (Dirichlet, clipped to bounds) starts and keeps the best converged solution (or, if none converge, the lowest-objective attempt, flagged via diagnostics["success"] = False).

Parameters:

Name Type Description Default
objective Callable[[ndarray], float]

Function of a weight array (in asset_names order) to minimize.

required
asset_names Sequence[str]

Asset order for the decision vector and the returned Series.

required
constraints Sequence[Any] | None

Constraint objects (see WeightBounds, GroupCap, TurnoverCap, NetExposure, GrossExposure).

None
jac Callable[[ndarray], ndarray] | None

Optional analytic gradient of objective, same signature.

None
n_restarts int

Number of SLSQP starting points to try.

4
seed int

RNG seed for the random restarts (deterministic across calls).

7
maxiter int

Max iterations per SLSQP attempt.

1000
extra_scipy_constraints Sequence[dict[str, Any]] | None

Additional raw scipy.optimize.minimize-style constraint dicts, for a one-off constraint specific to a single solver (e.g. target_return's "hit this exact return") that isn't one of the general, reusable Constraint types above.

None

Returns:

Type Description
Series

(weights, diagnostics) - weights is a Series indexed by asset_names;

dict[str, Any]

diagnostics carries success, objective_value, iterations,

tuple[Series, dict[str, Any]]

message, and n_restarts.

Source code in src/portpy/models/base.py
def solve_weights(
    objective: Callable[[np.ndarray], float],
    asset_names: Sequence[str],
    constraints: Sequence[Any] | None = None,
    jac: Callable[[np.ndarray], np.ndarray] | None = None,
    n_restarts: int = 4,
    seed: int = 7,
    maxiter: int = 1000,
    extra_scipy_constraints: Sequence[dict[str, Any]] | None = None,
) -> tuple[pd.Series, dict[str, Any]]:
    """
    Minimize `objective(w)` over portfolio weights via multi-start SLSQP.

    A full-investment constraint (`NetExposure(1.0)`) is applied automatically
    if `constraints` contains neither `NetExposure` nor `GrossExposure`. SLSQP's
    result is sensitive to its starting point, so this tries the equal-weight
    portfolio plus `n_restarts - 1` random feasible-ish (Dirichlet, clipped to
    bounds) starts and keeps the best converged solution (or, if none converge,
    the lowest-objective attempt, flagged via `diagnostics["success"] = False`).

    Args:
        objective: Function of a weight array (in `asset_names` order) to minimize.
        asset_names: Asset order for the decision vector and the returned Series.
        constraints: Constraint objects (see `WeightBounds`, `GroupCap`,
            `TurnoverCap`, `NetExposure`, `GrossExposure`).
        jac: Optional analytic gradient of `objective`, same signature.
        n_restarts: Number of SLSQP starting points to try.
        seed: RNG seed for the random restarts (deterministic across calls).
        maxiter: Max iterations per SLSQP attempt.
        extra_scipy_constraints: Additional raw `scipy.optimize.minimize`-style
            constraint dicts, for a one-off constraint specific to a single
            solver (e.g. `target_return`'s "hit this exact return") that isn't
            one of the general, reusable `Constraint` types above.

    Returns:
        `(weights, diagnostics)` - `weights` is a Series indexed by `asset_names`;
        `diagnostics` carries `success`, `objective_value`, `iterations`,
        `message`, and `n_restarts`.
    """
    asset_names = list(asset_names)
    n = len(asset_names)
    all_constraints = _ensure_exposure_constraint(constraints or [])
    bounds = resolve_bounds(all_constraints, asset_names)
    scipy_cons = resolve_scipy_constraints(all_constraints, asset_names) + list(extra_scipy_constraints or [])

    lows = np.array([b[0] for b in bounds], dtype=float)
    highs = np.array([b[1] for b in bounds], dtype=float)

    rng = np.random.default_rng(seed)
    starts = [np.full(n, 1.0 / n)]
    for _ in range(max(0, n_restarts - 1)):
        raw = rng.dirichlet(np.ones(n))
        starts.append(np.clip(lows + raw * (highs - lows), lows, highs))

    results = []
    for x0 in starts:
        res = sp_optimize.minimize(
            objective,
            x0,
            jac=jac,
            method="SLSQP",
            bounds=bounds,
            constraints=scipy_cons,
            options={"maxiter": maxiter, "ftol": 1e-12},
        )
        results.append(res)

    converged = [r for r in results if r.success]
    pool = converged if converged else results
    best = min(pool, key=lambda r: float(r.fun))

    weights = pd.Series(best.x, index=asset_names, name="weight")
    diagnostics = {
        "success": bool(best.success),
        "objective_value": float(best.fun),
        "iterations": int(best.nit),
        "message": str(best.message),
        "n_restarts": n_restarts,
    }
    return weights, diagnostics