Optimizers¶
All optimizers share the method(returns, ...) → PortfolioResult interface. See
the Optimizers guide for the mathematics and when to
use each.
Classical¶
classical
¶
Traditional / classical portfolio optimizers.
All optimizers share the method(returns, ...) -> PortfolioResult shape and
delegate constrained problems to the projected-gradient solver in
:mod:jaxfolio.optimizers.base. Objectives are defined as module-level
functions objective(w, params) (rather than per-call closures) so the
solver's jit cache hits across repeated calls — e.g. every rebalance of a
backtest recompiles nothing after the first solve.
Closed-form / exact solvers are used where they exist (inverse-volatility, risk-parity coordinate descent) and reported alongside the constrained result.
equal_weight
¶
equal_weight(returns) -> PortfolioResult
Equal-weight (1/N) portfolio — a famously hard-to-beat baseline.
Source code in src/jaxfolio/optimizers/classical.py
inverse_volatility
¶
inverse_volatility(returns) -> PortfolioResult
Inverse-volatility (naive risk parity) weighting.
Source code in src/jaxfolio/optimizers/classical.py
minimum_variance
¶
minimum_variance(
returns,
config: OptimizerConfig | None = None,
*,
constraints=None,
) -> PortfolioResult
Global minimum-variance portfolio.
Solves min w' Sigma w subject to the configured constraints via the
spectral projected-gradient solver, which converges to the exact
minimum-variance frontier vertex.
Source code in src/jaxfolio/optimizers/classical.py
mean_variance
¶
mean_variance(
returns,
risk_aversion: float = 1.0,
config: OptimizerConfig | None = None,
*,
constraints=None,
) -> PortfolioResult
Markowitz mean-variance portfolio.
Maximizes w'mu - (risk_aversion/2) w'Sigma w (equivalently minimizes its
negative) under the configured constraints. Larger risk_aversion tilts
toward lower variance.
Source code in src/jaxfolio/optimizers/classical.py
maximum_sharpe
¶
maximum_sharpe(
returns,
config: OptimizerConfig | None = None,
*,
constraints=None,
) -> PortfolioResult
Maximum-Sharpe (tangency) portfolio.
Minimizes the negative Sharpe ratio under the configured constraints. The Sharpe ratio is scale-invariant, so we optimize on the simplex directly.
Source code in src/jaxfolio/optimizers/classical.py
maximum_diversification
¶
maximum_diversification(
returns,
config: OptimizerConfig | None = None,
*,
constraints=None,
) -> PortfolioResult
Maximum-diversification portfolio (Choueifaty & Coignard, 2008).
Maximizes the diversification ratio (w'sigma) / sqrt(w'Sigma w) where
sigma is the vector of asset volatilities.
Source code in src/jaxfolio/optimizers/classical.py
risk_parity
¶
risk_parity(
returns,
config: OptimizerConfig | None = None,
*,
constraints=None,
) -> PortfolioResult
Equal-risk-contribution (ERC) / risk-parity portfolio.
Each asset contributes equally to total portfolio risk. Solved with the
cyclical coordinate descent of Griveau-Billion, Richard & Roncalli (2013) on
the convex program min 0.5 x'Sigma x - (1/N) sum(log x). Each coordinate
update solves a one-dimensional quadratic in closed form,
x_i <- (-beta_i + sqrt(beta_i^2 + 4 sigma_ii b_i)) / (2 sigma_ii) with
beta_i = (Sigma x)_i - sigma_ii x_i, which keeps x_i strictly
positive and is provably convergent regardless of covariance scale. Weights
are the normalized fixed point.
Named constraints are not supported: the log-barrier keeps every x_i
strictly positive, so no bound is ever active and the budget is imposed by
normalizing the fixed point rather than as a constraint — there is no
constrained program for a cap to enter. Use minimum_variance with a
GroupCap if you need one.
Source code in src/jaxfolio/optimizers/classical.py
kelly
¶
kelly(
returns,
config: OptimizerConfig | None = None,
*,
constraints=None,
) -> PortfolioResult
Approximate Kelly-optimal (log-growth) portfolio.
Maximizes E[log(1 + w'r)] estimated over the sample paths, which is the
growth-optimal criterion. Uses the projected-gradient solver over the return
matrix directly.
Source code in src/jaxfolio/optimizers/classical.py
min_cvar
¶
min_cvar(
returns,
alpha: float = 0.95,
config: OptimizerConfig | None = None,
*,
constraints=None,
) -> PortfolioResult
Minimum Conditional-Value-at-Risk portfolio (Rockafellar & Uryasev, 2000).
Minimizes CVaR at confidence alpha using the smooth auxiliary-variable
formulation CVaR = tau + 1/((1-alpha)T) sum(max(loss - tau, 0)), jointly
optimizing weights w and the VaR threshold tau.
The packed (w, tau) variable (tau unconstrained) and the piecewise
objective are ill-suited to a single scalar Barzilai-Borwein step, so this
optimizer substitutes Adam whenever config.solver is "spg". An
explicitly chosen optax optimizer is honored.
Source code in src/jaxfolio/optimizers/classical.py
black_litterman
¶
black_litterman(
returns,
views: dict[str, float] | None = None,
*,
view_confidence: float = 0.5,
tau: float = 0.05,
risk_aversion: float = 2.5,
market_weights: ndarray | None = None,
config: OptimizerConfig | None = None,
constraints=None,
) -> PortfolioResult
Black-Litterman portfolio blending market equilibrium with investor views.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
views
|
dict[str, float] | None
|
Absolute views mapping asset name -> expected (per-period) return. Only
assets present in the panel are used. If |
None
|
view_confidence
|
float
|
Scalar in |
0.5
|
tau
|
float
|
Uncertainty scaling on the prior covariance. |
0.05
|
risk_aversion
|
float
|
Market risk-aversion used for reverse optimization of equilibrium returns. |
2.5
|
market_weights
|
ndarray | None
|
Prior (market-cap) weights; defaults to equal weight. |
None
|
Source code in src/jaxfolio/optimizers/classical.py
587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 | |
Multi-period¶
Path-aware, cost-aware optimization over a horizon. See the multi-period guide for the cost model and its limits.
multiperiod
¶
Multi-period ("path-aware") mean-variance optimization with trading costs.
Every other optimizer in jaxfolio is single-period: it collapses the return panel
to (mu, Sigma) and emits one target weight vector, with no knowledge of what
you currently hold. Transaction costs are then charged after the fact by the
backtester, against a target chosen while blind to them. The familiar consequence
is an optimizer that says "sell half your largest position today" because nothing
in its objective knows that trade is expensive.
This module solves for the whole weight path at once. The variable is a
(T, N) matrix W whose row t is the portfolio to hold in period t,
anchored at the portfolio you hold now::
min_W sum_t [ -w_t'mu_t + (gamma/2) w_t' Sigma_t w_t ]
+ sum_t [ c_lin' |w_t - w_{t-1}| + c_quad' (w_t - w_{t-1})^2 ]
+ l2 * ||W||^2 with w_{-1} = w_prev
subject to each row independently lying in {lo <= w <= hi, sum(w) = 1}. The
quadratic impact term is what makes splitting a large trade across periods
strictly cheaper than executing it at once, so the solution is a glide path
from w_prev toward the long-run target rather than a jump.
Three implementation notes carry the design:
- The feasible set is a Cartesian product of the per-period sets, so the
Euclidean projection onto it is the product of the row projections — a plain
vmap(see :func:~jaxfolio.optimizers.base.select_projectionwithpath=True). That is why the shared projected-gradient solver needs no special casing to handle a 2-D variable. - The L1 trade cost is non-smooth exactly where the optimum sits (at zero
trade, for every asset that should not move). Applied directly, a first-order
method stalls ~1% above the true optimum regardless of solver family — the same
failure mode that limits :func:
~jaxfolio.optimizers.classical.min_cvar. The term is therefore Huber-smoothed, and because accuracy is not monotone in the smoothing width, the solver walks a ladder of shrinking widths and keeps whichever iterate is best under the exact, un-smoothed objective. Measured against a tightly converged CVXPY QP over asset counts 4-50 and six cost regimes, the worst relative gap is ~2e-4 and the typical one ~1e-6; selecting rather than trusting the final width is what buys that (it was 8-160% off otherwise). - Every cost travels as a traced parameter, so sweeping cost assumptions
never recompiles the solver kernel. Only
(horizon, n_assets)— baked into the variable's shape — keys a new compilation.
Turnover control here is soft: costs are priced, not capped. A hard budget
sum_t |w_t - w_{t-1}| <= tau couples the periods, which destroys the product
structure the row-wise projection relies on; see the multi-period guide.
solve_weight_path
¶
solve_weight_path(
mu_path,
cov,
w_prev,
*,
risk_aversion: float = 1.0,
c_lin=0.0,
c_quad=0.0,
l2_reg: float = 0.0,
trade_eps_rel: float = 0.1,
refine: int = DEFAULT_REFINE,
long_only: bool = True,
weight_bounds: tuple[float, float] = (0.0, 1.0),
solver: Any = "spg",
learning_rate: float | None = None,
max_iter: int = 2000,
tol: float = 1e-06,
solver_options: Mapping[str, Any] | None = None,
) -> tuple[Array, dict[str, Any]]
Solve the multi-period mean-variance problem for a whole weight path.
This is the primitive behind :func:multi_period_mean_variance, exposed for
callers who already hold forecasts and want the raw path back.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mu_path
|
Expected returns per period, shape |
required | |
cov
|
Covariance, either |
required | |
w_prev
|
Currently held weights, shape |
required | |
risk_aversion
|
float
|
Coefficient |
1.0
|
c_lin
|
Linear and quadratic trade-cost coefficients in decimal units, broadcast
against |
0.0
|
|
c_quad
|
Linear and quadratic trade-cost coefficients in decimal units, broadcast
against |
0.0
|
|
trade_eps_rel
|
float
|
Starting (widest) Huber half-width, relative to |
0.1
|
refine
|
int
|
Number of geometric refinements of the Huber width beyond the first stage, each shrinking it by a factor of three. Every stage warm-starts from the previous one and reuses the same compiled kernel, because the width is a traced parameter — so the ladder costs iterations, never compilations. The returned path is the stage that scored best under the exact (un-smoothed) objective, not simply the last one: accuracy is not monotone in the width, so selecting is what makes the result reliable. |
DEFAULT_REFINE
|
tol
|
float
|
Convergence tolerance. Defaults to |
1e-06
|
Returns:
| Type | Description |
|---|---|
tuple
|
|
Notes
A high c_lin relative to the Huber width legitimately leaves the
projected-gradient residual on a plateau far above tol while the answer is
exactly right (the correct action is "do not trade", and the residual carries
the un-actioned cost gradient). Read info["converged"] as a diagnostic,
not a failure.
Source code in src/jaxfolio/optimizers/multiperiod.py
156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 | |
multi_period_mean_variance
¶
multi_period_mean_variance(
returns,
horizon: int = 5,
*,
w_prev=None,
risk_aversion: float = 1.0,
costs: TradingCosts | None = None,
mu_path=None,
cov_path=None,
refine: int = DEFAULT_REFINE,
config: OptimizerConfig | None = None,
) -> PortfolioResult
Multi-period mean-variance portfolio: an optimal trajectory, not a target.
Solves for the whole weight path over horizon periods at once, starting
from the portfolio you currently hold, with trading costs priced inside the
objective. Because a large trade costs more than two half-sized ones under
quadratic impact, the optimizer spreads execution across periods instead of
jumping to the myopic target.
result.weights is the first row of the path — the allocation to hold
now, which is what you act on and what the backtester trades to. The full
path is on result.trajectory, and the long-run target is
result.metadata["terminal_weights"].
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
returns
|
Asset return panel (rows = periods, columns = assets). Sample moments from this panel are held constant across the horizon unless overridden. |
required | |
horizon
|
int
|
Number of periods |
5
|
w_prev
|
Currently held weights, aligned with the panel's columns. Defaults to a flat (all-zero) book, so the cost of establishing a position is priced. Need not sum to one. |
None
|
|
risk_aversion
|
float
|
Coefficient |
1.0
|
costs
|
TradingCosts | None
|
A :class: |
None
|
mu_path
|
Optional |
None
|
|
cov_path
|
Optional |
None
|
|
refine
|
int
|
Number of Huber-width refinements. Higher is more accurate and slower; the default is calibrated to land within ~1e-5 relative of the exact optimum. |
DEFAULT_REFINE
|
config
|
OptimizerConfig | None
|
Standard :class: |
None
|
Returns:
| Type | Description |
|---|---|
PortfolioResult
|
|
Notes
Costing is what drives the trajectory, so a bare call with no costs is
intentionally equivalent to repeating the single-period solution.
One compilation is spent per distinct (horizon, n_assets) pair, since the
horizon is baked into the variable's shape. Varying horizon per rebalance
in a backtest therefore recompiles each time; varying costs or
risk_aversion does not.
Turnover control is soft — a turnover budget can be priced but not guaranteed. See the multi-period guide.
Examples:
>>> import jaxfolio as jf
>>> panel = jf.generate_returns(n_assets=5, n_days=300, seed=0)
>>> res = jf.multi_period_mean_variance(
... panel, horizon=4, costs=jf.TradingCosts(spread_bps=10.0, impact_bps=5.0)
... )
>>> res.trajectory.shape
(4, 5)
Source code in src/jaxfolio/optimizers/multiperiod.py
367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 | |
Learning-based¶
learning
¶
Learning-based portfolio optimizers.
Two representative methods:
- :func:
deep_sharpe— an end-to-end differentiable allocation policy: a small MLP maps a window of recent returns to portfolio weights (via softmax), and is trained by gradient ascent to maximize the realized Sharpe ratio of the resulting portfolio. Pure JAX + optax, no flax dependency. - :func:
online_gradient— the exponentiated-gradient online portfolio (a Cover-style universal portfolio update) with sub-linear regret guarantees.
Both return a :class:PortfolioResult holding the final allocation; deep_sharpe
additionally stores the trained parameters so it can be rolled forward.
deep_sharpe
¶
deep_sharpe(
returns,
*,
lookback: int = 60,
hidden: tuple[int, ...] = (64, 32),
epochs: int = 300,
learning_rate: float = 0.001,
optimizer: str | Callable[..., Any] = "adam",
optimizer_options: Mapping[str, Any] | None = None,
seed: int = 0,
) -> PortfolioResult
Train a differentiable MLP allocation policy to maximize in-sample Sharpe.
The policy consumes the flattened trailing lookback window of returns and
emits long-only weights. Training maximizes the annualized Sharpe of the
strategy's realized returns across all windows. The reported allocation is
the policy applied to the most recent window.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
lookback
|
int
|
Length of the trailing return window fed to the policy. |
60
|
hidden
|
tuple[int, ...]
|
Hidden layer widths of the MLP. |
(64, 32)
|
epochs
|
int
|
Number of full-batch gradient ascent steps. |
300
|
optimizer
|
str | Callable[..., Any]
|
Which optax optimizer trains the policy — a name ( |
'adam'
|
optimizer_options
|
Mapping[str, Any] | None
|
Extra keyword arguments for the optax factory, e.g.
|
None
|
Source code in src/jaxfolio/optimizers/learning.py
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | |
online_gradient
¶
online_gradient(
returns, *, eta: float = 0.05
) -> PortfolioResult
Exponentiated-gradient (EG) universal online portfolio (Helmbold et al.).
Sequentially updates weights multiplicatively by the realized asset returns:
w_{t+1,i} ∝ w_{t,i} * exp(eta * r_{t,i} / (w_t . r_t)). This achieves
sub-linear regret versus the best constant-rebalanced portfolio in hindsight.
The reported allocation is the final weight vector; the wealth path is stored
in the metadata.
Source code in src/jaxfolio/optimizers/learning.py
Graph-based¶
graph
¶
Graph- and hierarchy-based portfolio optimizers.
- :func:
hierarchical_risk_parity— López de Prado's HRP: cluster assets by a correlation-distance dendrogram, quasi-diagonalize, then recursively bisect and allocate by inverse-variance. - :func:
hierarchical_equal_risk— HERC: like HRP but splits capital across the dendrogram's natural clusters by equal risk contribution. - :func:
mst_centrality— build the minimum spanning tree of the correlation network and weight assets by inverse degree/centrality (peripheral, less systemically coupled assets get more capital).
SciPy provides linkage and the MST; all allocation math stays in numpy.
hierarchical_risk_parity
¶
hierarchical_risk_parity(
returns, *, linkage_method: str = "single"
) -> PortfolioResult
Hierarchical Risk Parity (López de Prado, 2016).
A three-stage allocator that avoids inverting the (often ill-conditioned)
covariance matrix. First, assets are clustered by the correlation distance
sqrt(0.5 * (1 - rho)) into a dendrogram (tree clustering). Second, the
covariance matrix is quasi-diagonalized by reordering rows/columns to the
dendrogram's leaf order, placing similar assets adjacent. Third, capital is
assigned by recursive bisection: each cluster is split in two and capital
is allocated between the halves in inverse proportion to their inverse-
variance sub-portfolio variance. The result is a long-only, fully-invested
portfolio that is more robust out-of-sample than a direct minimum-variance
solve. Validated against PyPortfolioOpt's HRP (see the
validation matrix).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
returns
|
Asset return panel ( |
required | |
linkage_method
|
str
|
SciPy hierarchical-clustering linkage method used to build the
dendrogram. |
'single'
|
Returns:
| Type | Description |
|---|---|
PortfolioResult
|
Weights plus metadata: the |
Source code in src/jaxfolio/optimizers/graph.py
hierarchical_equal_risk
¶
hierarchical_equal_risk(
returns,
*,
n_clusters: int = 4,
linkage_method: str = "ward",
) -> PortfolioResult
Hierarchical Equal Risk Contribution (HERC).
Cut the dendrogram into n_clusters groups, allocate capital across
clusters by inverse cluster-variance, and within each cluster by inverse
variance. A robust, less order-sensitive cousin of HRP.
Source code in src/jaxfolio/optimizers/graph.py
mst_centrality
¶
mst_centrality(
returns, *, alpha: float = 1.0
) -> PortfolioResult
Minimum-spanning-tree centrality allocation.
Build the MST of the correlation-distance network; assets with lower degree
centrality (more peripheral, less coupled to the market core) receive more
weight: w_i ∝ 1 / (degree_i)^alpha.
Source code in src/jaxfolio/optimizers/graph.py
Solver core¶
The shared projected-gradient solver and portfolio-math primitives underlying the constrained classical methods.
base
¶
Projected-gradient solver shared by the constrained classical optimizers.
Every constrained objective (min-variance, mean-variance, max-Sharpe,
max-diversification, CVaR, ...) is minimized by the same routine: take a
gradient step on an unconstrained objective(w), then project w back onto
the feasible set. The loop runs inside jax.lax.while_loop so the whole solve
is a single jit-compiled kernel.
Two families of solver are available:
"spg"(default) — spectral projected gradient with Barzilai-Borwein step sizes. It needs no learning-rate tuning: the step adapts to the local curvature every iteration, so it converges to the constrained optimum (matching a dedicated QP solver) in far fewer iterations than a fixed-step method. Convergence is measured by the projected-gradient (KKT stationarity) norm||w - P(w - ∇f)||.- any optax optimizer — by name (
"adam","adamw","sgd","rmsprop", ...), by factory (optax.adamw), with hyperparameters passed assolver_options={"weight_decay": 1e-3}. See :mod:jaxfolio.solvers. These run a fixed-step projected loop whose smooth dynamics are preferable when differentiating through the optimizer to train an allocation policy, and which handle the non-smooth CVaR objective (with its free auxiliary variable) more gracefully than a single scalar BB step. Convergence is measured by the weight-update norm||w_{k+1} - w_k||— a proxy for optimality rather than a KKT test, so optimizers whose step does not shrink near the optimum (sign_sgd,lion, plainsgd) may run tomax_iteror stall early when the budget projection cancels a uniform step.
Performance note: :func:solve_constrained is jit-cached on the identity of
the objective/projection functions, so calling a built-in optimizer repeatedly
(e.g. at every backtest rebalance) compiles once and reuses the kernel. The
public :func:solve_projected_gradient accepts an arbitrary Python closure and
therefore re-traces per call — fine for one-off custom strategies.
The solver is part of that cache key, which is why it travels as a
:class:~jaxfolio.solvers.SolverSpec (a hashable, equality-stable tuple) rather
than as a live optax.GradientTransformation: names and module-level factories
hash identically across calls, a freshly built transformation does not. Changing
a solver_options value costs exactly one recompile.
make_projection
¶
make_projection(
long_only: bool,
weight_bounds: tuple[float, float],
budget: float = 1.0,
*,
constraints: Sequence[Any] = (),
assets: Sequence[str] | None = None,
) -> Callable[[Array], Array]
Return the w -> w projection matching the requested constraint set.
constraints accepts named specifications from
:mod:jaxfolio.constraints.spec (sector caps, per-asset bound vectors). They
need assets — the panel's asset names in order — to resolve names to
columns; pass integer-indexed specs to skip that. The closure form re-traces
per call anyway, so unlike :func:select_projection there is no cache concern
here.
Source code in src/jaxfolio/optimizers/base.py
projection_for
¶
Return (projection_fn, pparams) for a compiled constraint set.
The counterpart of :func:select_projection for named constraints. The
function object is looked up from the import-time table so its identity is
stable, and pparams carries every constraint value as a traced argument
— so changing a cap or a bound never recompiles. See
:mod:jaxfolio.constraints.compile for the full cache contract.
Source code in src/jaxfolio/optimizers/base.py
select_projection
¶
select_projection(
long_only: bool,
weight_bounds: tuple[float, float],
*,
aux: bool = False,
path: bool = False,
constraints: Sequence[Any] = (),
assets: Sequence[str] | None = None,
)
Return (projection_fn, pparams) for the cached kernel.
aux=True selects the variant that projects only the weight-block of a
packed [w, tau] vector (used by the CVaR optimizer). path=True
selects the row-wise variant for a (T, N) weight path (used by the
multi-period optimizer). pparams is a tuple of Python floats — passed as
a traced argument to the kernel, and identical for all three variants.
constraints accepts named specifications from
:mod:jaxfolio.constraints.spec, which need assets to resolve names to
columns. With no named constraints this returns exactly what it always did:
the same function object and the same Python-float tuple, so no pre-existing
solve changes kernel, cache entry, or dtype.
Source code in src/jaxfolio/optimizers/base.py
solve_constrained
¶
solve_constrained(
objective,
obj_params,
w0: Array,
projection,
proj_params,
*,
solver: str | Callable[..., Any] | SolverSpec = "spg",
learning_rate: float | None = None,
max_iter: int = 2000,
tol: float = 1e-07,
solver_options: Mapping[str, Any] | None = None,
) -> tuple[Array, dict[str, Array]]
Cached entry point for the built-in optimizers.
objective and projection must be module-level functions (see
:mod:jaxfolio.optimizers.classical) so the underlying jit cache hits.
solver is "spg", any optax optimizer name or factory, or a
pre-resolved :class:~jaxfolio.solvers.SolverSpec.
Returns (weights, info) with info["iterations"] and
info["residual"] (the solver's convergence measure at the final iterate).
Source code in src/jaxfolio/optimizers/base.py
solve_projected_gradient
¶
solve_projected_gradient(
objective: Objective,
w0: Array,
projection: Callable[[Array], Array],
*,
learning_rate: float | None = None,
max_iter: int = 2000,
tol: float = 1e-07,
solver: str | Callable[..., Any] | SolverSpec = "spg",
solver_options: Mapping[str, Any] | None = None,
) -> tuple[Array, dict[str, Array]]
Minimize objective over the feasible set defined by projection.
Accepts arbitrary Python closures objective(w) -> scalar and
projection(w) -> w (the shape used by custom strategies and the
toolkit). Because the closure identity changes per call this re-traces
each time — for hot loops over a built-in optimizer, prefer the cached
:func:solve_constrained. solver is "spg", any optax optimizer name
or factory, or a pre-resolved :class:~jaxfolio.solvers.SolverSpec.
Returns (weights, info) with info["iterations"] and
info["residual"].
Source code in src/jaxfolio/optimizers/base.py
portfolio_return
¶
portfolio_variance
¶
portfolio_volatility
¶
sharpe_ratio
¶
Sharpe ratio of a weight vector given moments.
Source code in src/jaxfolio/optimizers/base.py
Constraints¶
Named constraint specifications and the projections that enforce them. See the constraints guide for the partition requirement, the feasibility guarantees, and what is not expressible.
spec
¶
Named constraint specifications.
Where :mod:jaxfolio.constraints.projections provides anonymous projections
(w -> w maps onto a feasible set), this module lets a caller name the
constraints that shape a portfolio::
cons = [
jf.GroupCap("tech", ["AAPL", "MSFT", "NVDA"], max=0.30),
jf.Box(lower=0.0, upper=0.10),
]
res = jf.minimum_variance(returns, constraints=cons)
Naming is what makes a solution explainable: once a cap has an identity, the
solver's Lagrange multiplier for it becomes a shadow price attributable to
"tech" rather than an anonymous number.
These are pure specifications — frozen, hashable, and free of any JAX or solver
dependency (they import stdlib only). They validate their own structure at
construction: types, tuple coercion, finiteness, min <= max. They can not
validate feasibility, which additionally needs the asset universe and the
budget; :func:jaxfolio.constraints.compile.compile_constraints does that. The
two-stage split mirrors :class:~jaxfolio.types.OptimizerConfig, which likewise
resolves what it can at construction and defers the rest.
Hashability is load-bearing, not incidental: these objects travel on
:class:~jaxfolio.types.OptimizerConfig, which is a frozen dataclass used as a
functools.partial payload and a dict key throughout the backtester. Every
sequence field is therefore coerced to a tuple.
The supported feasible set is
{w : l <= w <= u, 1'w = b, L_k <= a_k'w <= U_k} where the rows a_k must
have pairwise disjoint supports — each asset may be touched by at most one
named row. That restriction is not an oversight; see
:mod:jaxfolio.constraints.structured for why it is exactly what makes the
projection exact and the shadow prices trustworthy.
Not expressible here: rows with coefficients other than 1 (a general
LinearConstraint for beta/factor-exposure limits — the inner bisection
generalizes readily, but the outer budget bisection is only provably monotone
for all-ones rows, so it is tracked rather than guessed at), overlapping or
nested groups (structural, per above),
cardinality and minimum-position-size limits (non-convex — no Euclidean
projection exists for a projected-gradient method to converge through), and
gross-exposure/leverage caps (||w||_1 <= G, which needs a third bisection
level and is only meaningful for long-short books — tracked, not yet
implemented).
Constraint
¶
Bases: ABC
Base class for every named constraint.
Deliberately not a dataclass: it declares no fields, so subclasses stay free to order their own (a dataclass base would force every subclass field to come after the base's and to carry a default).
RowConstraint
¶
Bases: Constraint
A named scalar row L <= a'w <= U.
Group caps and general linear constraints differ only in their coefficients, so the compiler and the dual extractor treat both through this interface.
terms
abstractmethod
¶
Budget
dataclass
¶
Bases: Constraint
Total invested weight: sum(w) == total.
Always present. Instantiate one explicitly only to override the default
fully-invested 1.0 — e.g. Budget(0.0) for a dollar-neutral book.
Box
dataclass
¶
Box(
lower: float | tuple[float, ...] = 0.0,
upper: float | tuple[float, ...] = 1.0,
assets: tuple[str, ...] | tuple[int, ...] | None = None,
name: str = "box",
)
Bases: Constraint
Per-asset weight bounds lower <= w_i <= upper.
lower and upper are each either a scalar applied to every asset in
scope, or a per-asset sequence. assets=None (the default) scopes the box
to the whole universe and replaces the bounds implied by
OptimizerConfig.long_only / weight_bounds; an assets-scoped box
intersects with whatever is already in force, so per-name overrides
compose::
Box(lower=0.0, upper=0.10) # every asset capped at 10%
Box(upper=0.02, assets=["ILLIQ"], name="illiquid") # tighter on one name
A per-asset sequence must match the length of assets, or of the whole
universe when assets is None.
width
¶
Length of the per-asset bounds, or None if both are scalars.
GroupCap
dataclass
¶
GroupCap(
name: str,
assets: tuple[str, ...] | tuple[int, ...],
max: float | None = None,
min: float | None = None,
)
Bases: RowConstraint
A cap and/or floor on the summed weight of a named group of assets.
The canonical sector constraint::
GroupCap("tech", ["AAPL", "MSFT", "NVDA"], max=0.30) # tech <= 30%
GroupCap("energy", ["XOM", "CVX"], min=0.05) # energy >= 5%
GroupCap("bonds", ["TLT", "IEF"], min=0.20, max=0.40) # a band
Groups must be disjoint — no asset may appear in two of them. See
:func:jaxfolio.constraints.compile.compile_constraints, which enforces it,
and :mod:jaxfolio.constraints.structured for why.
GroupFloor
¶
GroupFloor(
name: str,
assets: Sequence[str] | Sequence[int],
min: float,
) -> GroupCap
A named floor on a group's summed weight — sugar for GroupCap(min=...).
Returns a :class:GroupCap rather than a distinct class so that a group can
never carry a floor and a cap that disagree, and so the compiler and the
attribution report have exactly one row type to reason about.
Source code in src/jaxfolio/constraints/spec.py
compile
¶
Compile named constraint specifications into a solver-ready projection.
:mod:jaxfolio.constraints.spec describes constraints in the user's vocabulary —
asset names, sector caps, per-name overrides. The solver needs index arrays and
a projection function. This module bridges the two, and it is also where every
error a user can actually make gets caught, with the constraint named and the gap
quantified.
Resolution happens host-side, in numpy, once per solve — right after
:func:jaxfolio.results.moments yields the asset names. Nothing here runs under
jit.
The jit-cache contract
:func:~jaxfolio.optimizers.base.solve_constrained keys its cache on the
identity of the projection function plus the shapes of its traced arguments. So
the split matters:
- Static — the projection kind (which selects the function object) and the
shapes
n_assetsandn_rows. - Traced — every constraint value: bounds, budget, row limits, and even the group-id vector.
Consequence: changing a cap from 30% to 32%, tightening a per-asset bound, or reassigning which tickers belong to a sector costs zero recompiles. Only changing the number of rows, or the asset count, adds a cache entry. That is what makes a what-if re-solve cheap enough to be interactive.
Backwards compatibility is exact, not approximate: with no named constraints and
scalar bounds, :meth:CompiledConstraints.projection returns the same function
objects and the same Python-float tuples that
:func:~jaxfolio.optimizers.base.select_projection returned before this module
existed. Not "equivalent" — identical, so no existing solve gains a cache entry,
changes dtype, or moves a single bit.
InfeasibleConstraints
¶
Bases: ValueError
The requested constraint set admits no feasible portfolio.
Raised at compile time, before any moments are estimated or any solve is attempted. Because the supported rows have disjoint supports, the feasibility test is exact — necessary and sufficient — so this is never a false alarm, and conversely a set that compiles can always be satisfied.
CompiledConstraints
dataclass
¶
CompiledConstraints(
kind: str,
assets: tuple[str, ...],
budget: float,
lower: tuple[float, ...],
upper: tuple[float, ...],
row_names: tuple[str, ...],
row_lower: tuple[float, ...],
row_upper: tuple[float, ...],
group_of: tuple[int, ...],
specs: tuple[Constraint, ...],
long_only: bool = True,
weight_bounds: tuple[float, float] = (0.0, 1.0),
)
A resolved, feasible constraint set ready to hand to the solver.
Everything is stored host-side as tuples so the object stays hashable and
picklable; the traced JAX arrays are built on demand by :meth:projection.
That also makes :meth:perturb a plain field replacement, which is what lets
a what-if re-solve reuse the compiled kernel.
Attributes:
| Name | Type | Description |
|---|---|---|
kind |
str
|
Which projection kernel is needed — see the module constants. Part of the jit cache key. |
assets |
tuple[str, ...]
|
Asset names, in panel order. |
budget |
float
|
Required |
lower, upper |
Effective per-asset bounds, length |
|
row_names |
tuple[str, ...]
|
Names of the constraint rows, index-aligned with |
row_lower, row_upper |
Effective row limits, already clamped to what the box alone permits
( |
|
group_of |
tuple[int, ...]
|
Length |
specs |
tuple[Constraint, ...]
|
The original specifications, for reporting and :meth: |
long_only, weight_bounds |
The implicit bounds this set was compiled against. Retained so
:meth: |
members
¶
Asset names constrained by row name.
projection
¶
Return (projection_fn, pparams) for the cached solver kernel.
aux=True selects the variant that projects only the weight block of a
packed [w, tau] vector (the CVaR optimizer); path=True the
row-wise variant for a (T, N) weight path. Imported lazily because
:mod:jaxfolio.optimizers.base imports this package.
Source code in src/jaxfolio/constraints/compile.py
pparams
¶
The traced projection parameters for this constraint set.
Bounds and limits are returned as jnp arrays for the vector kinds and
as plain Python floats for the two scalar kinds — the latter reproducing
byte-for-byte what the pre-existing select_projection produced.
Source code in src/jaxfolio/constraints/compile.py
perturb
¶
perturb(name: str, **changes: float) -> CompiledConstraints
Return a copy with one constraint's limits changed.
Only values move, so the perturbed set keeps the same kind and row
count and therefore the same compiled kernel — the basis of a cheap
what-if re-solve. Recompiles the specs so the new limits are re-validated
and re-clamped, and so an infeasible relaxation is still caught.
Source code in src/jaxfolio/constraints/compile.py
compile_constraints
¶
compile_constraints(
constraints: Sequence[Constraint] | None,
assets: Sequence[str],
*,
long_only: bool = True,
weight_bounds: tuple[float, float] = (0.0, 1.0),
) -> CompiledConstraints
Resolve constraint specifications against an asset universe.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
constraints
|
Sequence[Constraint] | None
|
Specifications from :mod: |
required |
assets
|
Sequence[str]
|
Asset names in panel order, as returned by
:func: |
required |
long_only
|
bool
|
The implicit bounds, normally |
True
|
weight_bounds
|
bool
|
The implicit bounds, normally |
True
|
Raises:
| Type | Description |
|---|---|
InfeasibleConstraints
|
The set admits no feasible portfolio. Exact, not heuristic. |
ValueError
|
Two rows share an asset (see :mod: |
KeyError
|
A constraint names an asset that is not in the panel. |
Source code in src/jaxfolio/constraints/compile.py
234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 | |
check_feasible
¶
check_feasible(
constraints: Sequence[Constraint] | None,
assets: Sequence[str],
*,
long_only: bool = True,
weight_bounds: tuple[float, float] = (0.0, 1.0),
) -> None
Validate a constraint set without solving anything.
Raises the same errors :func:compile_constraints would. Useful for
validating user input up front — e.g. in a UI — since the feasibility test is
exact and costs nothing.
Source code in src/jaxfolio/constraints/compile.py
structured
¶
Exact Euclidean projections onto structured constraint sets.
These extend :mod:jaxfolio.constraints.projections from "box + budget" (with
scalar bounds) to "per-asset box + budget + named group rows", and they
additionally return the Lagrange multipliers of the constraints they enforce
— which is what makes an optimizer's solution explainable.
Why the projection must be exact
The projected-gradient solvers in :mod:jaxfolio.optimizers.base are not merely
"iterate and clip". _spg_loop stops on ||w - P(w - grad f)||, which
certifies KKT stationarity only if P is the true Euclidean projection onto a
convex set, and its Barzilai-Borwein step alpha = s's / s'y assumes the
iterates come from a nonexpansive map. An inexact P — an alternating
projection truncated after a few sweeps, a penalty, an ADMM inner loop — breaks
both: the residual stops meaning what the stopping test claims, and the step size
can oscillate. Under jax.lax.while_loop with a fixed iteration budget that
fails silently. So the projection here is exact, in the same sense
project_box_budget is exact: a bracketed bisection on a monotone dual
function, with a fixed iteration count and no tolerance to tune.
Why the group rows must have disjoint supports
Stationarity of the projection subproblem gives, with lam on the budget and
theta_k on row k::
w_i = clip(v_i - lam - theta_{k(i)}, l_i, u_i)
When each asset is touched by at most one row, theta_k decouples given
lam: each group's sum is an independent 1-D monotone function of its own
theta_k, so the whole projection is a fixed 2-level nest of bisections, each
level monotone. Once supports overlap, the multipliers couple and
the projection needs Dykstra or ADMM — inexact at any finite iteration count, per
above, with multipliers that only converge in the limit. That is why
:func:jaxfolio.constraints.compile.compile_constraints rejects overlap outright
rather than degrading quietly.
Multipliers
The bisection is the dual solve, so the multipliers are a by-product rather than
an extra computation. project_box_budget already computes the budget
multiplier (its tau) and discards it; the *_duals variants here keep it,
along with the per-row and per-asset multipliers. Scaled by 1/step, these are
the KKT multipliers of the original portfolio problem — see
:mod:jaxfolio.attribution.
Note on the budget correction
project_box_budget finishes with a uniform (budget - sum(w)) / n nudge,
which can push coordinates outside the box when the box and budget nearly
conflict. The kernels here instead distribute that residual over the free set
only — coordinates strictly inside their bounds and in a non-binding group — so
feasibility is never traded for a tighter budget. Any leftover is reported as
feasibility_residual rather than hidden. project_box_budget itself is left
untouched: it is public API with pinned numerics.
ProjectionDuals
¶
Bases: NamedTuple
A projected point together with the multipliers that produced it.
A NamedTuple so it is a JAX pytree: it flows through jit, vmap
and lax control flow with no registration.
Attributes:
| Name | Type | Description |
|---|---|---|
weights |
Array
|
The projected point. |
budget |
Array
|
Multiplier |
rows |
Array
|
|
bounds |
Array
|
|
identified |
Array
|
|
budget_identified |
Array
|
Is |
feasibility_residual |
Array
|
|
project_box_budget_vec
¶
project_box_budget_vec(
v: Array,
lower: Array,
upper: Array,
budget: Array,
*,
max_iter: int = 50,
) -> Array
Project v onto {w : lower <= w <= upper, sum(w) = budget}.
The per-asset-bounds generalization of
:func:jaxfolio.constraints.projections.project_box_budget: lower and
upper are (n,) arrays rather than scalars. Same method — bisect the
budget multiplier tau, since w(tau) = clip(v - tau, lower, upper) is
monotone decreasing in tau — and the same feasibility requirement,
sum(lower) <= budget <= sum(upper).
Source code in src/jaxfolio/constraints/structured.py
project_box_budget_duals
¶
project_box_budget_duals(
v: Array,
lower: Array,
upper: Array,
budget: Array,
*,
max_iter: int = 50,
) -> ProjectionDuals
Box + budget projection with its multipliers.
The row-free case of :func:project_grouped_duals: rows is a single
all-zero sentinel entry and identified is [False], so a caller can
treat both kernels uniformly.
Source code in src/jaxfolio/constraints/structured.py
project_grouped_duals
¶
project_grouped_duals(
v: Array,
lower: Array,
upper: Array,
budget: Array,
gid: Array,
g_lower: Array,
g_upper: Array,
*,
outer_iter: int = _OUTER_ITER,
inner_iter: int = _INNER_ITER,
) -> ProjectionDuals
Project onto box + budget + disjoint group rows, returning multipliers.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
v
|
Array
|
|
required |
lower
|
Array
|
|
required |
upper
|
Array
|
|
required |
budget
|
Array
|
Scalar; |
required |
gid
|
Array
|
|
required |
g_lower
|
Array
|
|
required |
g_upper
|
Array
|
|
required |
Returns:
| Type | Description |
|---|---|
class:`ProjectionDuals`. Validated against CVXPY over 40 random problems
|
|
(``n`` in 6..40, 2..4 rows, long-only and long-short): weights agree to
|
|
2.1e-8, the budget multiplier to 2.2e-9, and every *identified* row
|
|
multiplier to 3.9e-9. See ``ProjectionDuals.identified`` for the rows whose
|
|
multiplier is not pinned by the data.
|
|
Notes
The inner target is clip(freesum_k(lam), L_k, U_k) — not an early exit
when the row is already satisfied. That distinction is load-bearing: the
clipped-target form is what makes the dual decomposition unique, so lam
and theta cannot drift into offsetting each other. It also makes "row is
slack" an exact test (target == freesum), which is how theta_k is
forced to a clean zero instead of the arbitrary value a degenerate bracket
would otherwise return.
The outer bracket needs no theta term even though w depends on
theta: the inner solve pins each group's sum to
clip(freesum_k(lam), L_k, U_k) regardless of how large theta_k gets,
so the total is a function of lam alone — and a non-increasing one, since
every freesum_k and every ungrouped coordinate is non-increasing in
lam and clip is monotone.
Source code in src/jaxfolio/constraints/structured.py
288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 | |
project_grouped
¶
project_grouped(
v: Array,
lower: Array,
upper: Array,
budget: Array,
gid: Array,
g_lower: Array,
g_upper: Array,
*,
outer_iter: int = _OUTER_ITER,
inner_iter: int = _INNER_ITER,
) -> Array
Project onto box + budget + disjoint group rows.
See :func:project_grouped_duals for the algorithm and the meaning of every
argument; this is the primal-only wrapper used inside the solver loop.
Source code in src/jaxfolio/constraints/structured.py
Attribution¶
The explainability layer: which constraints bind, what they cost, and what the report will refuse to claim. See the explainability guide.
attribution
¶
Explain why an optimizer produced the weights it did.
A convex optimizer is normally a black box: parameters in, weights out. When an asset comes back at 0% there is nothing in the result that says whether it was excluded on its own merits or crowded out by a constraint somewhere else. This module answers that question, using the Lagrange multipliers the projection already computes.
The recovery, in one identity
Every solver here minimizes f over a convex set C by projected gradient,
so at a solution w* with g = grad f(w*), stationarity says w* is a
fixed point: w* == P_C(w* - s g) for any small step s > 0. Projecting
v = w* - s g and keeping the projection's own multipliers therefore hands
back the multipliers of the portfolio problem, divided by s::
lambda = lambda_proj / s on the budget
theta_k = theta_proj[k] / s on named row k
rc_i = -beta_proj[i] / s reduced cost of asset i
No second solve, no least-squares fit, no factorization — one extra gradient and
two extra projections, all outside lax.while_loop and so fully jit- and
vmap-safe.
The pricing decomposition
The reduced cost splits into exactly the terms a portfolio manager would name::
rc_i = intrinsic_i + imposed_i where
intrinsic_i = g_i + lambda the asset on its own merits, net of the
cost of capital
imposed_i = theta_{k(i)} what its group's constraint charges it
At an active bound, rc_i != 0 and the question "what put this asset here?" is
answered by whichever term is largest. Ranking the terms, not the
multipliers, is the whole trick. The naive rule — take the largest multiplier
among the constraints touching asset i — is wrong in a way that looks right:
at w_i = 0 the box multiplier equals the entire reduced cost, so it dominates
every group term by construction and box_lower would win every single time.
"binding: tech" could never fire. This is the textbook LP column-pricing identity
("reduced cost = own cost + the shadow price of every resource consumed"), and it
degrades correctly: with no groups, every zero attributes to own, which is
the truthful answer.
Honesty
Multipliers are only meaningful at a converged, non-degenerate solution, and the
number of ways that can fail is not small. Every one of them is checked and every
report carries a quality grade; when a number cannot be stood behind, it is
None rather than a plausible-looking guess. See :class:ConstraintReport for
the full list, and the guide for what the report will refuse to claim.
Units and signs
Multipliers are in the units of the objective, per period — they are not
annualized, unlike expected_return / volatility / sharpe on the
result. Annualizing would be wrong in different ways for different objectives
(linear in the covariance for variance, square-root for Sharpe), so the raw value
is reported and the field says so.
shadow_price is signed for the objective as the user names it: positive
means relaxing the constraint improves that objective. Internally every
multiplier is stored non-negative for a binding cap, and only the presentation
flips on sense. The invariant that must never break: relaxing a constraint
can only ever weakly improve the optimum.
SolverDuals
dataclass
¶
SolverDuals(
weights: ndarray,
gradient: ndarray,
lower: ndarray,
upper: ndarray,
budget: float,
row_names: tuple[str, ...],
row_lower: tuple[float, ...],
row_upper: tuple[float, ...],
group_of: tuple[int, ...],
budget_multiplier: float,
row_multipliers: tuple[float, ...],
reduced_costs: ndarray,
row_identified: tuple[bool, ...],
budget_identified: bool,
kkt_residual: float,
gradient_norm: float,
step: float,
step_check: float,
aux_stationarity: float | None = None,
objective: str = "",
sense: str = "minimize",
smooth: bool = True,
convex: bool = True,
l2_reg: float = 0.0,
solver_kind: str = "spg",
tol: float = 1e-07,
)
First-order information captured at a solved portfolio.
Attached to :attr:jaxfolio.types.PortfolioResult.attribution. Deliberately
numeric and self-contained — plain numpy arrays and tuples, no JAX arrays, no
closures, no reference to the returns panel — so a result stays lightweight
and picklable, and so :func:explain is pure post-processing that never
re-solves.
Attributes:
| Name | Type | Description |
|---|---|---|
weights, gradient |
The solution and the objective gradient there. |
|
lower, upper |
Effective per-asset bounds in force. |
|
budget, row_names, row_lower, row_upper, group_of |
The constraint set, as resolved by
:func: |
|
budget_multiplier, row_multipliers, reduced_costs |
The recovered multipliers, in objective units per period. Non-negative for a binding cap; see the module docstring for the sign convention. |
|
row_identified, budget_identified |
Whether each multiplier is pinned by the data at all. See
:class: |
|
kkt_residual, gradient_norm, step, step_check |
Convergence evidence. |
|
aux_stationarity |
float | None
|
For packed |
tol |
float
|
The convergence tolerance the solve was asked for. The quality grade is judged against it: "did this solve do what it was told to?" is a fairer and more portable question than any fixed absolute threshold. |
objective, sense, smooth, convex |
Provenance the report needs to know what it may claim. |
stationarity
property
¶
||d|| / ||grad f|| — the scale-free measure the quality gate uses.
summary
¶
A small JSON-serializable digest for PortfolioResult.metadata.
Source code in src/jaxfolio/attribution.py
ConstraintDual
dataclass
¶
ConstraintDual(
name: str,
kind: str,
activity: float,
lower: float,
upper: float,
slack: float,
multiplier: float | None,
shadow_price: float | None,
status: str,
members: tuple[str, ...] = (),
)
One constraint, its activity, and what it costs.
status is one of:
binding
Active and carrying a non-zero multiplier: relaxing it will move the
optimum.
weakly_active
Active but with a zero multiplier — a degenerate vertex. Relaxing it will
not move anything. Never reported as a cause.
inactive
Slack.
unidentified
Active, but the multiplier is not pinned by the data: every member sits on
a bound, so any split of the shadow price between this row and those bounds
is an equally valid KKT certificate. shadow_price is None.
AssetAttribution
dataclass
¶
AssetAttribution(
asset: str,
weight: float,
status: str,
reduced_cost: float | None,
intrinsic_cost: float | None,
imposed: tuple[tuple[str, float], ...],
primary: str | None,
primary_kind: str | None,
primary_shadow_price: float | None,
confidence: str,
reason: str | None = None,
)
Why one asset holds the weight it does.
primary is "own" when the asset is there (or absent) on its own
merits, a constraint name when a constraint put it there, or None when the
answer is genuinely ambiguous — see confidence.
ConstraintReport
dataclass
¶
ConstraintReport(
method: str,
objective: str,
sense: str,
assets: tuple[str, ...],
weights: ndarray,
available: bool,
quality: str,
reason: str | None,
stationarity: float | None,
kkt_residual: float | None,
step_check: float | None,
budget_multiplier: float | None,
budget_shadow_price: float | None,
budget_note: str | None,
constraints: tuple[ConstraintDual, ...],
assets_detail: tuple[AssetAttribution, ...],
warnings: tuple[str, ...] = (),
aux_stationarity: float | None = None,
l2_reg: float = 0.0,
_index: dict[str, int] = dict(),
)
The result of :func:explain — per-constraint and per-asset attribution.
quality grades everything else in the report:
exact
Converged to machine precision; multipliers are trustworthy.
approximate
Converged loosely, or the step probe disagreed. Numbers are shown with a
warning attached.
unreliable
Not converged, or the objective is non-smooth at the optimum. Shadow
prices are suppressed entirely — only primal facts are reported.
unavailable
This optimizer has no multipliers at all; reason says why.
Four renderings, for four audiences: :meth:explain_text reads the report as
prose, :meth:to_table lays the same facts out as fixed-width tables for a
terminal or a log, :meth:to_dict / :meth:to_json serialize it whole for a
machine, and :meth:to_frame / :meth:constraints_frame hand it to Polars.
All five are pure views over the same fields — none of them recompute anything.
binding
¶
binding() -> tuple[ConstraintDual, ...]
to_frame
¶
Per-asset attribution as a Polars frame with an asset column.
Source code in src/jaxfolio/attribution.py
constraints_frame
¶
Per-constraint activity and shadow price, keyed by name.
A different question from :meth:to_frame: which constraint is costing
the portfolio the most, rather than what put each asset where it is.
Source code in src/jaxfolio/attribution.py
explain_text
¶
A readable rendering of the report.
Source code in src/jaxfolio/attribution.py
to_table
¶
The report as fixed-width tables — for a terminal, a log, or a print-out.
Same facts as :meth:explain_text, laid out as columns instead of prose: a
header block, one row per named constraint, one row per asset, and the notes
that qualify them. Plain text, and every string jaxfolio itself wrote is folded
to ASCII, so the report survives a log file, a CI artifact and a non-UTF-8
console. Asset and constraint names are yours and pass through verbatim — a
report that mangles a ticker to fit a charset would be the worse trade.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
max_assets
|
int | None
|
Show only the first |
None
|
notes
|
bool
|
Include the trailing notes block (units caveat, quality reason, warnings). |
True
|
Examples:
Source code in src/jaxfolio/attribution.py
723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 | |
to_dict
¶
The whole report as a JSON-serializable dict.
Nothing is dropped and nothing is rounded: every constraint, every asset and
every diagnostic, with plain Python scalars throughout (no numpy types). A
number the report declines to stand behind stays None rather than
becoming 0.0 — the distinction is the point of the report, so it must
survive serialization. schema is there so a consumer can tell versions
apart if this grows.
The digest on result.metadata is the small, always-present cousin of
this: five keys for a backtest log, where this is the whole picture.
Source code in src/jaxfolio/attribution.py
to_json
¶
The report as a JSON string. indent=None for a single compact line.
solver_duals
¶
solver_duals(
objective,
obj_params,
compiled: CompiledConstraints,
weights,
*,
objective_name: str,
sense: str = "minimize",
smooth: bool = True,
convex: bool = True,
l2_reg: float = 0.0,
solver_kind: str = "spg",
tol: float = 1e-07,
aux_dim: int = 0,
) -> SolverDuals
Recover the KKT multipliers of a solved portfolio problem.
Runs after the solve, outside any lax control flow, at the cost of one
gradient evaluation and two projections. Solver-agnostic by construction,
which is what lets it retrofit a real stationarity measure onto the optax
solvers (whose own info["residual"] is a weight-update norm, explicitly
not a KKT test).
objective(z, obj_params) is the module-level objective that was minimized;
weights is the returned iterate. For a packed [w, tau] variable pass
aux_dim=1 and the full packed vector — the projection's normal cone
factorizes as N_C(w) x {0}, so the weight-block multipliers are unaffected
and tau's stationarity comes back separately.
Source code in src/jaxfolio/attribution.py
240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 | |
explain
¶
explain(
result: PortfolioResult,
*,
prim_tol: float | None = None,
dual_tol: float | None = None,
) -> ConstraintReport
Explain a solved portfolio: which constraints bind, and what they cost.
Pure post-processing — reads only the payload captured at solve time and never
re-solves. Always returns a well-formed report: optimizers that have no
multipliers (closed-form weightings, the graph methods, the learning policies)
come back with available=False and a specific reason, with the primal
facts still filled in.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
prim_tol
|
float | None
|
How close to a bound counts as on it. Defaults to |
None
|
dual_tol
|
float | None
|
How large a multiplier counts as non-zero. Defaults to |
None
|
Source code in src/jaxfolio/attribution.py
948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 | |