Skip to content

Construction, Optimization & Management

portpy.models answers three questions, in order: what should I assume about returns and risk (.estimators), what weights should I hold given those assumptions (.optimization / .construction), and how do I get from my current book to that target and stay honest about drift afterward (.management). Every call below also works as a plain function (from portpy.models.optimization import mean_variance) - .models is just the auto-filling, portfolio-bound convenience layer, the same pattern .metrics already uses.

Estimating inputs

Every optimizer needs an expected-return vector (mu) and a covariance matrix (Sigma) - neither is observable, both are estimates:

mu = portfolio.models.estimators.expected_returns(method="james_stein")
cov = portfolio.models.estimators.covariance(method="ledoit_wolf")

method="mean_historical" (the default) is the simplest and noisiest choice; "james_stein" shrinks toward the cross-sectional grand mean, and "capm_implied" (needs benchmark=) backs out returns from each asset's beta instead of its own history. For covariance, "sample" is the raw matrix; "ledoit_wolf" and "shrinkage" trade a little bias for much better conditioning when your history isn't long relative to the number of assets. See the API reference for every method and its caveats.

portpy.models.estimators.factor_models also has capm, fama_french, linear_regression, rolling_regression, and factor_attribution - thin, opinionated wrappers around statsmodels OLS.

Solving for weights

result = portfolio.models.optimize(method="max_sharpe")
result.weights          # pandas Series, indexed by asset
result.summary()        # weights sorted by size
result.explain()        # what max_sharpe is, how to read it, caveats

Called bare like this, expected_returns/cov_matrix are auto-filled from the portfolio's own data (default estimator methods) - pass them explicitly to use a different estimate. method is one of "mean_variance", "max_sharpe", "min_variance", "target_return", "target_volatility", "black_litterman", "hierarchical_risk_parity", "risk_parity", "risk_budgeting", "maximum_diversification" - see API reference for what each one optimizes and needs.

.models.build(method=...) is the same thing starting one step earlier - it estimates mu/Sigma for you first:

result = portfolio.models.build(method="mean_variance", covariance_kwargs={"method": "ledoit_wolf"})

.models.efficient_frontier(n_points=50) traces the whole frontier instead of one point - result.weights is the max-Sharpe point on the curve, and result.summary() returns the full return/volatility/weights table.

Every one of these returns a ModelResult - portpy.explain's model-layer counterpart to MetricResult. .diagnostics carries solver internals (success, objective_value, iterations); always check diagnostics["success"] before trusting a target_return/target_volatility solve, since an infeasible target fails to converge silently rather than raising.

Constraints

from portpy.models.construction import GroupCap, TurnoverCap, WeightBounds

result = portfolio.models.optimize(
    method="mean_variance",
    constraints=[
        WeightBounds(low=0.0, high=0.40),                                  # no more than 40% in any one asset
        GroupCap(groups={"equity": ["AAPL", "MSFT", "JPM"]}, max_weight=0.60),
        TurnoverCap(max_turnover=0.15),                                    # current_weights auto-filled from the portfolio
    ],
)

A full-investment constraint (sum(w) == 1) is applied automatically unless you pass GrossExposure instead (for a long/short book, e.g. a 130/30 fund uses GrossExposure(1.6)). hierarchical_risk_parity only supports WeightBounds (applied post-hoc); every other constraint on it is dropped with a warning, since recursive bisection has no natural way to target a group cap or turnover budget.

TurnoverCap.max_turnover caps the raw sum(abs(w - current_weights)) - not divided by 2. That makes it twice metrics.costs.turnover_from_weights's "one-way turnover" convention (selling 10% of A to buy 10% of B reads as 10% one-way turnover, but 20% under TurnoverCap's raw-sum convention) - TurnoverCap(max_turnover=0.15) above is a 7.5% one-way turnover budget, not 15%.

Managing a book afterward

An optimizer gives you a destination, not a plan to get there:

plan = portfolio.models.management.rebalance(target_weights=result, method="threshold", threshold=0.03, cost_bps=8)
plan.diagnostics["trades"]           # signed per-asset trade
plan.diagnostics["turnover"]         # one-way turnover
plan.diagnostics["estimated_cost"]   # flat, linear-in-turnover cost model

method="threshold" only trades assets that have drifted past threshold; "full"/"calendar" trade all the way to target regardless (the two are identical here - rebalance is a stateless, point-in-time trade-list generator, not a scheduler).

report = portfolio.models.management.monitor(limits={"max_weight": 0.40, "max_gross": 1.0})
report["ok"]           # bool
report["breaches"]     # [{"type", "asset"/"group", "value", "limit"}, ...]

comparison = portfolio.models.management.compare(other_portfolio_or_result)
comparison.diagnostics["weight_delta"]   # always available
comparison.diagnostics["metric_delta"]   # only when both sides are Portfolios with return history

What's not here yet

ModelResult.plot() raises NotImplementedError until portpy.visualization ships - use .summary() and .explain() in the meantime. portpy.models.simulation (stochastic processes, Monte Carlo, ARIMA/GARCH, PCA/regime detection) is a separate, later stage - see the Roadmap.