Portfolio-optimization examples¶
Portfolio optimization turns forecasts and risk estimates into explicit target weights. The problem object keeps objective, constraints, costs, covariance policy, benchmark, current portfolio, and decision time separate.
Define aligned inputs¶
from dataclasses import replace
import numpy as np
import pandas as pd
assets = pd.Index(["asset-a", "asset-b", "asset-c", "asset-d"], name="asset")
expected_returns = pd.Series([0.07, 0.05, 0.03, 0.01], index=assets)
covariance = pd.DataFrame(
[
[0.040, 0.010, 0.004, 0.002],
[0.010, 0.030, 0.006, 0.003],
[0.004, 0.006, 0.020, 0.004],
[0.002, 0.003, 0.004, 0.015],
],
index=assets,
columns=assets,
)
current_weights = pd.Series([0.25, 0.25, 0.25, 0.25], index=assets)
benchmark_weights = pd.Series([0.25, 0.25, 0.25, 0.25], index=assets)
assert covariance.index.equals(covariance.columns)
assert expected_returns.index.equals(covariance.index)
Persistra uses axis equality, not set equality. Reindex deliberately before constructing the problem.
Solve a long-only mean-variance problem¶
from persistra.portfolio import (
MeanVarianceObjective,
NetExposureConstraint,
PortfolioProblem,
WeightBounds,
optimize_portfolio,
)
base_problem = PortfolioProblem(
covariance=covariance,
expected_returns=expected_returns,
current_weights=current_weights,
benchmark_weights=benchmark_weights,
objective=MeanVarianceObjective(risk_aversion=4.0),
constraints=(
WeightBounds(0.0, 0.50),
NetExposureConstraint(1.0, 1.0),
),
as_of=pd.Timestamp("2025-03-31"),
)
base_result = optimize_portfolio(base_problem)
assert abs(float(base_result.weights.sum()) - 1.0) < 1e-8
print(base_result.weights)
print(base_result.objective_breakdown)
Cash is the residual 1 - sum(weights). A fixed net exposure of one makes this example fully
invested. Without that constraint, the objective may retain cash.
Compare supported objectives¶
from persistra.portfolio import (
ActiveMeanVarianceObjective,
MinimumTrackingErrorObjective,
MinimumVarianceObjective,
)
minimum_variance = optimize_portfolio(
replace(base_problem, objective=MinimumVarianceObjective(), expected_returns=None)
)
minimum_tracking_error = optimize_portfolio(
replace(base_problem, objective=MinimumTrackingErrorObjective(), expected_returns=None)
)
active_mean_variance = optimize_portfolio(
replace(base_problem, objective=ActiveMeanVarianceObjective(risk_aversion=4.0))
)
print(minimum_variance.variance)
print(minimum_tracking_error.tracking_error)
print(active_mean_variance.expected_return)
Tracking-error objectives operate on weights relative to benchmark_weights. Active
mean-variance maximizes expected active return against tracking-error variance.
Constrain gross, net, turnover, and tracking error¶
from persistra.portfolio import (
GrossExposureConstraint,
TrackingErrorConstraint,
TurnoverConstraint,
)
controlled_problem = replace(
base_problem,
constraints=(
WeightBounds(-0.30, 0.50),
GrossExposureConstraint(1.20),
NetExposureConstraint(0.95, 1.00),
TurnoverConstraint(0.30),
TrackingErrorConstraint(0.15),
),
)
controlled_result = optimize_portfolio(controlled_problem)
print(controlled_result.exposures)
print(controlled_result.turnover)
print(controlled_result.constraint_diagnostics)
One-way turnover includes risky assets and residual cash. Constraint diagnostics report lower and upper residuals and whether each boundary is binding.
Constrain factor and general linear exposures¶
from persistra.portfolio import FactorExposureConstraint, LinearExposureConstraint
factor_exposures = pd.DataFrame(
{
"value": [1.0, 0.5, -0.4, -0.8],
"momentum": [-0.2, 0.8, 0.4, -0.6],
},
index=assets,
)
sector_loadings = pd.DataFrame(
{
"technology": [1.0, 1.0, 0.0, 0.0],
"defensive": [0.0, 0.0, 1.0, 1.0],
},
index=assets,
)
exposure_problem = replace(
base_problem,
factor_exposures=factor_exposures,
constraints=(
WeightBounds(0.0, 0.50),
NetExposureConstraint(1.0, 1.0),
FactorExposureConstraint(
lower=pd.Series({"value": -0.10, "momentum": -0.10}),
upper=pd.Series({"value": 0.30, "momentum": 0.30}),
),
LinearExposureConstraint(
name="sector",
loadings=sector_loadings,
lower=pd.Series({"technology": 0.30, "defensive": 0.30}),
upper=pd.Series({"technology": 0.70, "defensive": 0.70}),
),
),
)
exposure_result = optimize_portfolio(exposure_problem)
print(exposure_result.factor_exposures)
print(exposure_result.linear_exposures)
FactorExposureConstraint uses PortfolioProblem.factor_exposures.
LinearExposureConstraint carries its own named loading system, making it suitable for sector,
country, duration, asset-class, or other caller-defined linear controls.
Condition a covariance matrix explicitly¶
from persistra.portfolio import CovariancePolicy
conditioned_result = optimize_portfolio(
replace(
base_problem,
covariance_policy=CovariancePolicy(
diagonal_shrinkage=0.20,
minimum_eigenvalue=1e-6,
),
)
)
print(conditioned_result.covariance_diagnostics)
The policy first shrinks toward the supplied diagonal and then floors eigenvalues. The result records raw and conditioned eigenvalues, condition numbers, and adjustment magnitude.
Model symmetric, asymmetric, and quadratic costs¶
from persistra.portfolio import (
AsymmetricTransactionCostPenalty,
LinearTransactionCostPenalty,
QuadraticTransactionCostPenalty,
)
cost_problem = replace(
base_problem,
penalties=(
LinearTransactionCostPenalty(0.0002),
AsymmetricTransactionCostPenalty(
buy_rates=pd.Series(0.0004, index=assets),
sell_rates=pd.Series(0.0007, index=assets),
),
QuadraticTransactionCostPenalty(
rates=pd.Series([0.002, 0.002, 0.003, 0.004], index=assets)
),
),
)
cost_result = optimize_portfolio(cost_problem)
print(cost_result.objective_breakdown[
[
"linear_transaction_cost_term",
"quadratic_transaction_cost_term",
"transaction_cost_term",
]
])
Rates must use units compatible with expected returns and covariance. Every penalty requires current weights. The aggregate cost term reconciles its linear and quadratic components.
Optimize a dated path¶
from persistra.portfolio import optimize_portfolio_path
path_problems = tuple(
replace(
base_problem,
expected_returns=expected_returns.shift(position, fill_value=expected_returns.iloc[-1]),
as_of=pd.Timestamp("2025-03-31") + pd.offsets.MonthEnd(position),
)
for position in range(3)
)
path = optimize_portfolio_path(path_problems, failure_policy="hold_previous")
assert len(path.steps) == 3
print(path.weights)
print([step.status for step in path.steps])
Every step uses one fixed ordered asset axis and a strictly increasing as_of. A successful
solution becomes the next step's current weights and numerical warm start. hold_previous
records later infeasibility without fabricating a solution.
Supply a solver backend¶
Implement PortfolioSolver to connect another continuous optimizer. This wrapper records the
neutral problem and delegates to the default backend:
from persistra.portfolio import (
PortfolioSolverProblem,
PortfolioSolverResult,
ScipySlsqpSolver,
)
class RecordingSolver:
name = "recording-slsqp"
def __init__(self) -> None:
self.problem: PortfolioSolverProblem | None = None
def solve(self, problem: PortfolioSolverProblem) -> PortfolioSolverResult:
self.problem = problem
return ScipySlsqpSolver().solve(problem)
solver = RecordingSolver()
custom_result = optimize_portfolio(base_problem, solver=solver)
assert solver.problem is not None
assert custom_result.solver == "recording-slsqp"
print(custom_result.solver_statistics)
A backend receives differentiable objective and gradient callables, variable bounds, solver-neutral equality and nonnegative-inequality constraints, an initial point, tolerance, and iteration limit. It returns normalized values, status, iterations, and statistics. Persistra still validates the returned portfolio against the original problem.
Backtest dated targets¶
Vectorized backtesting starts from target rows and a return or price panel. It does not model orders or fills:
from persistra.portfolio import BacktestTiming, backtest_portfolio
market_dates = pd.date_range("2025-03-31", periods=8, freq="D")
market_returns = pd.DataFrame(
[
[0.002, 0.001, -0.001, 0.000],
[0.001, -0.002, 0.001, 0.000],
[-0.001, 0.002, 0.000, 0.001],
[0.003, 0.001, -0.001, 0.001],
[0.000, -0.001, 0.002, 0.001],
[0.001, 0.001, 0.001, -0.001],
[-0.002, 0.000, 0.002, 0.001],
[0.001, 0.002, 0.000, -0.001],
],
index=market_dates,
columns=assets,
)
targets = pd.DataFrame(
[base_result.weights, cost_result.weights],
index=market_dates[[0, 3]],
)
backtest = backtest_portfolio(
targets,
returns=market_returns,
timing=BacktestTiming(decision_lag=0, execution_lag=1),
transaction_cost_bps=5.0,
benchmarks={"equal-weight": benchmark_weights},
)
print(backtest.equity)
print(backtest.turnover)
print(backtest.benchmark_comparison)
Targets are signal-observation dates. Timing maps them to decisions and first holding periods. Use Trading Engine replay when partial fills, direct orders, fees, margin, or execution-model behavior can change the conclusion.