Skip to content
python 3.11+ powered by JAX license MIT

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
def equal_weight(returns) -> PortfolioResult:
    """Equal-weight (1/N) portfolio — a famously hard-to-beat baseline."""
    mu, cov, names, _ = _moments(returns)
    n = len(names)
    w = jnp.full(n, 1.0 / n)
    return _finalize(w, names, "Equal Weight", mu, cov, 0.0)

inverse_volatility

inverse_volatility(returns) -> PortfolioResult

Inverse-volatility (naive risk parity) weighting.

Source code in src/jaxfolio/optimizers/classical.py
def inverse_volatility(returns) -> PortfolioResult:
    """Inverse-volatility (naive risk parity) weighting."""
    mu, cov, names, _ = _moments(returns)
    vol = jnp.sqrt(jnp.clip(jnp.diag(cov), 1e-18, None))  # floor guards zero-variance assets
    inv = 1.0 / vol
    w = inv / jnp.sum(inv)
    return _finalize(w, names, "Inverse Volatility", mu, cov, 0.0)

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
def 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.
    """
    config = _cfg(config, constraints)
    mu, cov, names, _ = _moments(returns)
    n = len(names)

    projection, pparams, compiled = _projection(config, names)
    params = {"cov": cov, "l2": jnp.asarray(config.l2_reg)}
    w, info = solve_constrained(
        _minvar_objective,
        params,
        jnp.full(n, 1.0 / n),
        projection,
        pparams,
        **_solver_kwargs(config),
    )
    return _finalize(
        w,
        names,
        "Minimum Variance",
        mu,
        cov,
        config.risk_free_rate,
        {"iterations": int(info["iterations"])},
        attribution=_duals(
            config,
            _minvar_objective,
            params,
            compiled,
            w,
            name="variance",
            sense="minimize",
        ),
    )

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
def 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.
    """
    config = _cfg(config, constraints)
    mu, cov, names, _ = _moments(returns)
    n = len(names)

    projection, pparams, compiled = _projection(config, names)
    params = {
        "mu": mu,
        "cov": cov,
        "risk_aversion": jnp.asarray(risk_aversion),
        "l2": jnp.asarray(config.l2_reg),
    }
    w, info = solve_constrained(
        _meanvar_objective,
        params,
        jnp.full(n, 1.0 / n),
        projection,
        pparams,
        **_solver_kwargs(config),
    )
    return _finalize(
        w,
        names,
        "Mean-Variance",
        mu,
        cov,
        config.risk_free_rate,
        {"risk_aversion": risk_aversion, "iterations": int(info["iterations"])},
        attribution=_duals(
            config,
            _meanvar_objective,
            params,
            compiled,
            w,
            name="mean-variance utility",
            sense="maximize",
        ),
    )

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
def 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.
    """
    config = _cfg(config, constraints)
    mu, cov, names, _ = _moments(returns)
    n = len(names)
    rf = config.risk_free_rate

    projection, pparams, compiled = _projection(config, names)
    params = {"mu": mu, "cov": cov, "rf": jnp.asarray(rf), "l2": jnp.asarray(config.l2_reg)}
    w, info = solve_constrained(
        _sharpe_objective,
        params,
        jnp.full(n, 1.0 / n),
        projection,
        pparams,
        **_solver_kwargs(config),
    )
    return _finalize(
        w,
        names,
        "Maximum Sharpe",
        mu,
        cov,
        rf,
        {"iterations": int(info["iterations"])},
        attribution=_duals(
            config,
            _sharpe_objective,
            params,
            compiled,
            w,
            name="Sharpe ratio",
            sense="maximize",
            convex=False,
        ),
    )

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
def 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.
    """
    config = _cfg(config, constraints)
    mu, cov, names, _ = _moments(returns)
    n = len(names)
    vol = jnp.sqrt(jnp.diag(cov))

    projection, pparams, compiled = _projection(config, names)
    params = {"cov": cov, "vol": vol, "l2": jnp.asarray(config.l2_reg)}
    w, info = solve_constrained(
        _maxdiv_objective,
        params,
        jnp.full(n, 1.0 / n),
        projection,
        pparams,
        **_solver_kwargs(config),
    )
    return _finalize(
        w,
        names,
        "Maximum Diversification",
        mu,
        cov,
        config.risk_free_rate,
        {"iterations": int(info["iterations"])},
        attribution=_duals(
            config,
            _maxdiv_objective,
            params,
            compiled,
            w,
            name="diversification ratio",
            sense="maximize",
            convex=False,
        ),
    )

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
def 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.
    """
    config = _cfg(config, constraints)
    _reject_constraints(config, "risk_parity", "minimum_variance or mean_variance")
    mu, cov, names, _ = _moments(returns)
    n = len(names)
    b = 1.0 / n
    sigma = np.asarray(cov, dtype=float)
    # Floor the diagonal so a zero-variance asset cannot produce a 0/0 coordinate
    # update or an infinite warm start.
    diag = np.clip(np.diag(sigma), 1e-18, None)

    # Warm start from inverse-volatility weights (a good ERC approximation).
    x = 1.0 / np.sqrt(diag)
    x = x / x.sum()

    iters = 0
    while iters < config.max_iter:
        iters += 1
        x_prev = x.copy()
        for i in range(n):
            beta_i = sigma[i] @ x - diag[i] * x[i]  # (Sigma x)_i without the i-th term
            x[i] = (-beta_i + np.sqrt(beta_i**2 + 4.0 * diag[i] * b)) / (2.0 * diag[i])
        if np.linalg.norm(x / x.sum() - x_prev / x_prev.sum()) < config.tol:
            break

    w = x / x.sum()
    port_var = float(w @ sigma @ w)
    rc = w * (sigma @ w) / port_var
    return _finalize(
        w,
        names,
        "Risk Parity (ERC)",
        mu,
        cov,
        config.risk_free_rate,
        {
            "iterations": iters,
            "risk_contributions": np.asarray(rc, dtype=float).tolist(),
        },
    )

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
def 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.
    """
    config = _cfg(config, constraints)
    mu, cov, names, mat = _moments(returns)
    n = len(names)

    projection, pparams, compiled = _projection(config, names)
    params = {"mat": mat, "l2": jnp.asarray(config.l2_reg)}
    w, info = solve_constrained(
        _kelly_objective,
        params,
        jnp.full(n, 1.0 / n),
        projection,
        pparams,
        **_solver_kwargs(config),
    )
    return _finalize(
        w,
        names,
        "Kelly (log-growth)",
        mu,
        cov,
        config.risk_free_rate,
        {"iterations": int(info["iterations"])},
        attribution=_duals(
            config,
            _kelly_objective,
            params,
            compiled,
            w,
            name="expected log growth",
            sense="maximize",
        ),
    )

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
def 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.
    """
    config = _cfg(config, constraints)
    mu, cov, names, mat = _moments(returns)
    n = len(names)
    t = mat.shape[0]
    scale = 1.0 / ((1.0 - alpha) * t)

    projection, pparams, compiled = _projection(config, names, aux=True)
    params = {"mat": mat, "scale": jnp.asarray(scale), "l2": jnp.asarray(config.l2_reg)}

    # Pack (w, tau) into a single vector; tau starts at 0.
    z0 = jnp.concatenate([jnp.full(n, 1.0 / n), jnp.zeros(1)])
    spec = config.solver_spec()
    if spec.kind == "spg":
        spec = resolve_solver("adam")
    z, info = solve_constrained(
        _cvar_objective,
        params,
        z0,
        projection,
        pparams,
        solver=spec,
        learning_rate=config.learning_rate,
        max_iter=config.max_iter,
        tol=config.tol,
    )
    w = z[:-1]
    tau = z[-1]
    losses = -(mat @ w)
    cvar = float(tau + scale * jnp.sum(jnp.maximum(losses - tau, 0.0)))
    return _finalize(
        w,
        names,
        f"Min CVaR ({int(alpha * 100)}%)",
        mu,
        cov,
        config.risk_free_rate,
        {"alpha": alpha, "cvar": cvar, "var": float(tau), "iterations": int(info["iterations"])},
        attribution=_duals(
            config,
            _cvar_objective,
            params,
            compiled,
            z,
            name="CVaR",
            sense="minimize",
            smooth=False,
            aux_dim=1,
        ),
    )

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, the result reduces to the reverse-optimized equilibrium (market) portfolio.

None
view_confidence float

Scalar in (0, 1] controlling how tightly views are weighted (higher = more confident).

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
def 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: np.ndarray | None = None,
    config: OptimizerConfig | None = None,
    constraints=None,
) -> PortfolioResult:
    """Black-Litterman portfolio blending market equilibrium with investor views.

    Parameters
    ----------
    views:
        Absolute views mapping asset name -> expected (per-period) return. Only
        assets present in the panel are used. If ``None``, the result reduces to
        the reverse-optimized equilibrium (market) portfolio.
    view_confidence:
        Scalar in ``(0, 1]`` controlling how tightly views are weighted (higher
        = more confident).
    tau:
        Uncertainty scaling on the prior covariance.
    risk_aversion:
        Market risk-aversion used for reverse optimization of equilibrium
        returns.
    market_weights:
        Prior (market-cap) weights; defaults to equal weight.
    """
    config = _cfg(config, constraints)
    mat, names = as_matrix(returns)
    cov = sample_covariance(mat)
    n = len(names)

    w_mkt = (
        jnp.full(n, 1.0 / n) if market_weights is None else jnp.asarray(market_weights, dtype=float)
    )
    # Reverse-optimized (implied equilibrium) excess returns: pi = lambda * Sigma * w_mkt
    pi = risk_aversion * (cov @ w_mkt)

    if views:
        idx = {name: i for i, name in enumerate(names)}
        used = [(idx[a], q) for a, q in views.items() if a in idx]
        if used:
            rows = jnp.zeros((len(used), n))
            q = jnp.zeros(len(used))
            for k, (i, val) in enumerate(used):
                rows = rows.at[k, i].set(1.0)
                q = q.at[k].set(val)
            p = rows
            tau_cov = tau * cov
            # Omega: per-view uncertainty, scaled by confidence. Both the prior
            # view variance (a zero-variance asset) and confidence -> 0 would make
            # Omega singular; floor the confidence and the diagonal to stay stable.
            conf = float(np.clip(view_confidence, 1e-3, 1.0))
            omega_diag = jnp.diag(p @ tau_cov @ p.T) / conf
            omega_diag = jnp.clip(omega_diag, 1e-10, None)
            omega_inv = jnp.diag(1.0 / omega_diag)  # Omega is diagonal by construction
            # Posterior mean (He & Litterman closed form). Ridge-regularize the
            # prior-covariance inverse so a singular Sigma (e.g. a zero-variance
            # asset) does not blow the posterior up to NaN.
            ridge = 1e-10 * jnp.eye(n)
            a = jnp.linalg.inv(tau_cov + ridge)
            b = p.T @ omega_inv @ p
            post_cov = jnp.linalg.inv(a + b + ridge)
            post_mu = post_cov @ (a @ pi + p.T @ omega_inv @ q)
        else:
            post_mu = pi
    else:
        post_mu = pi

    # Mean-variance optimize with the posterior returns (reuse the shared objective).
    projection, pparams, compiled = _projection(config, names)
    params = {
        "mu": post_mu,
        "cov": cov,
        "risk_aversion": jnp.asarray(risk_aversion),
        "l2": jnp.asarray(config.l2_reg),
    }
    w, info = solve_constrained(
        _meanvar_objective, params, w_mkt, projection, pparams, **_solver_kwargs(config)
    )
    mu_sample = mean_returns(mat)
    return _finalize(
        w,
        names,
        "Black-Litterman",
        mu_sample,
        cov,
        config.risk_free_rate,
        {
            "posterior_returns": np.asarray(post_mu, dtype=float).tolist(),
            "n_views": len(views) if views else 0,
            "iterations": int(info["iterations"]),
        },
        attribution=_duals(
            config,
            _meanvar_objective,
            params,
            compiled,
            w,
            name="mean-variance utility (posterior)",
            sense="maximize",
        ),
    )

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_projection with path=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 (T, N).

required
cov

Covariance, either (N, N) (shared across periods) or (T, N, N).

required
w_prev

Currently held weights, shape (N,). Need not sum to one — the zero vector is a valid "flat book", and the cost of establishing a position from it is priced correctly.

required
risk_aversion float

Coefficient gamma on the per-period variance term.

1.0
c_lin

Linear and quadratic trade-cost coefficients in decimal units, broadcast against (T, N). Scalars, (N,) and (T, N) all work.

0.0
c_quad

Linear and quadratic trade-cost coefficients in decimal units, broadcast against (T, N). Scalars, (N,) and (T, N) all work.

0.0
trade_eps_rel float

Starting (widest) Huber half-width, relative to budget / N.

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-6 rather than the package-wide 1e-7 because the float32 projected-gradient norm floors around 1e-6 on this problem, so a tighter value only buys wasted iterations.

1e-06

Returns:

Type Description
tuple

(W, info) where W has shape (T, N) and info carries iteration counts, the per-stage widths, the final residual, and both the smoothed and exact objective values.

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
def 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 = 1e-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-6,
    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
    ----------
    mu_path:
        Expected returns per period, shape ``(T, N)``.
    cov:
        Covariance, either ``(N, N)`` (shared across periods) or ``(T, N, N)``.
    w_prev:
        Currently held weights, shape ``(N,)``. Need **not** sum to one — the
        zero vector is a valid "flat book", and the cost of establishing a
        position from it is priced correctly.
    risk_aversion:
        Coefficient ``gamma`` on the per-period variance term.
    c_lin, c_quad:
        Linear and quadratic trade-cost coefficients in decimal units, broadcast
        against ``(T, N)``. Scalars, ``(N,)`` and ``(T, N)`` all work.
    trade_eps_rel:
        Starting (widest) Huber half-width, relative to ``budget / N``.
    refine:
        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.
    tol:
        Convergence tolerance. Defaults to ``1e-6`` rather than the package-wide
        ``1e-7`` because the float32 projected-gradient norm floors around
        ``1e-6`` on this problem, so a tighter value only buys wasted iterations.

    Returns
    -------
    tuple
        ``(W, info)`` where ``W`` has shape ``(T, N)`` and ``info`` carries
        iteration counts, the per-stage widths, the final residual, and both the
        smoothed and exact objective values.

    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.
    """
    mu_path = jnp.asarray(mu_path, dtype=float)
    cov = jnp.asarray(cov, dtype=float)
    if mu_path.ndim != 2:
        raise ValueError(f"mu_path must be 2-D (T, N), got shape {mu_path.shape}")
    horizon, n = mu_path.shape
    if cov.ndim == 2:
        objective = _mp_shared_cov
        if cov.shape != (n, n):
            raise ValueError(
                f"cov shape {cov.shape} does not match {n} assets; expected ({n}, {n})"
            )
    elif cov.ndim == 3:
        objective = _mp_path_cov
        if cov.shape != (horizon, n, n):
            raise ValueError(
                f"cov shape {cov.shape} does not match the path; expected ({horizon}, {n}, {n})"
            )
    else:
        raise ValueError(f"cov must be (N, N) or (T, N, N), got shape {cov.shape}")
    if refine < 0:
        raise ValueError(f"refine must be non-negative, got {refine}")
    if trade_eps_rel <= 0.0:
        raise ValueError(f"trade_eps_rel must be strictly positive, got {trade_eps_rel}")

    projection, pparams = select_projection(long_only, weight_bounds, path=True)
    budget = pparams[-1]

    # ``dtype=`` is required, not decorative: jnp.full(n, 1/n) is weak-typed and
    # jnp.tile preserves that, but the solver's output is strongly typed — so a
    # weak-typed start would make stage 2 a *different* jit cache key and compile
    # the kernel twice for what is one problem.
    w_prev_j = jnp.asarray(w_prev, dtype=mu_path.dtype).reshape(-1)
    if w_prev_j.shape != (n,):
        raise ValueError(
            f"w_prev shape {w_prev_j.shape} does not match {n} assets; expected ({n},)"
        )
    # Project the warm start onto the feasible set before anything reads it. The
    # solver would project internally anyway, but the stage-selection below also
    # scores this iterate as a candidate — and an *infeasible* point can score
    # lower than every feasible one, which would let it win and be returned. That
    # matters concretely whenever ``w_prev`` is not a valid portfolio: a partially
    # invested book (the backtester hands over exactly that before its first
    # rebalance) or holdings that violate the configured bounds.
    W = projection(jnp.tile(w_prev_j, (horizon, 1)), pparams)

    params = {
        "mu_path": mu_path,
        "cov": cov,
        "w_prev": w_prev_j,
        "risk_aversion": jnp.asarray(risk_aversion, dtype=mu_path.dtype),
        "l2": jnp.asarray(l2_reg, dtype=mu_path.dtype),
        "c_lin": jnp.broadcast_to(jnp.asarray(c_lin, dtype=mu_path.dtype), (n,))
        if jnp.ndim(c_lin) <= 1
        else jnp.asarray(c_lin, dtype=mu_path.dtype),
        "c_quad": jnp.broadcast_to(jnp.asarray(c_quad, dtype=mu_path.dtype), (n,))
        if jnp.ndim(c_quad) <= 1
        else jnp.asarray(c_quad, dtype=mu_path.dtype),
        "trade_eps": jnp.asarray(1.0, dtype=mu_path.dtype),
    }

    # Walk a ladder of shrinking Huber widths, warm-starting each stage from the
    # previous one, and keep the iterate that is best under the *exact* objective.
    #
    # Selecting rather than simply taking the last stage is what makes this
    # reliable. A single width cannot serve every problem: when the linear cost is
    # comparable to the whole mean-variance curvature (common at 50-100bps), a
    # narrow band makes the smoothed problem so stiff that the projected-gradient
    # method crawls, while a wide band biases the answer. Worse, accuracy is *not*
    # monotone in the width, so trusting the narrowest stage is measurably wrong —
    # it was observed 8% to 160% off the true optimum. Because the un-smoothed
    # objective is cheap to evaluate, the ladder can be treated as a search over
    # smoothing levels judged by the real criterion, which bounds the error by the
    # best stage instead of the last. That took the worst case across a grid of
    # panel sizes and cost regimes from ~163% down to ~1e-4.
    eps = float(trade_eps_rel) * budget / n
    stage_iters: list[int] = []
    stage_eps: list[float] = []
    stage_exact: list[float] = []
    best_W = W
    # ``W`` is feasible here (projected above), so this is a legitimate candidate:
    # holding still is often genuinely optimal once trading is expensive enough.
    best_exact = float(_exact_objective(W, {**params, "trade_eps": jnp.asarray(eps)}))
    best_stage = -1  # -1 => the (projected) no-trade path was never beaten
    best_residual = float("inf")
    for stage in range(refine + 1):
        params["trade_eps"] = jnp.asarray(eps, dtype=mu_path.dtype)
        W, info = solve_constrained(
            objective,
            params,
            W,
            projection,
            pparams,
            solver=solver,
            learning_rate=learning_rate,
            max_iter=max_iter,
            tol=tol,
            solver_options=solver_options,
        )
        stage_iters.append(int(info["iterations"]))
        stage_eps.append(eps)
        value = float(_exact_objective(W, params))
        stage_exact.append(value)
        if value < best_exact:
            best_exact, best_W, best_stage = value, W, stage
            best_residual = float(info["residual"])
        eps *= _REFINE_FACTOR

    W = best_W
    # Report the smoothed objective at the width that actually produced ``W``.
    params["trade_eps"] = jnp.asarray(
        stage_eps[best_stage] if best_stage >= 0 else stage_eps[0], dtype=mu_path.dtype
    )
    smoothed = float(objective(W, params))
    exact = best_exact
    residual = best_residual
    d = np.asarray(W - _path_prev(W, w_prev_j))
    turnover_path = np.abs(d).sum(axis=1)
    c_lin_np = np.asarray(params["c_lin"])
    c_quad_np = np.asarray(params["c_quad"])

    info = {
        "iterations": int(sum(stage_iters)),
        "stage_iterations": tuple(stage_iters),
        "stage_trade_eps": tuple(stage_eps),
        "stage_objective_exact": tuple(stage_exact),
        "selected_stage": int(best_stage),
        "trade_eps": float(stage_eps[best_stage] if best_stage >= 0 else stage_eps[0]),
        "residual": float(residual),
        "converged": bool(float(residual) <= tol),
        "turnover_path": turnover_path,
        "total_turnover": float(turnover_path.sum()),
        "total_cost": float((c_lin_np * np.abs(d)).sum() + (c_quad_np * d**2).sum()),
        "objective_smoothed": smoothed,
        "objective_exact": exact,
        "smoothing_bias": exact - smoothed,
    }
    return W, info

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 T to plan over.

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 gamma on the per-period variance term. Larger tilts the whole path toward lower variance.

1.0
costs TradingCosts | None

A :class:~jaxfolio.types.TradingCosts spec. None means frictionless, in which case every row of the path collapses onto the single-period mean-variance solution — pass costs to get path behavior.

None
mu_path

Optional (T, N) expected-return term structure, overriding the constant sample mean. Use this to express a changing forecast.

None
cov_path

Optional (T, N, N) covariance term structure, overriding the constant sample covariance.

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:~jaxfolio.types.OptimizerConfig. Note the default here uses tol=1e-6; an explicitly passed config is honored as given.

None

Returns:

Type Description
PortfolioResult

weights is the first path row, trajectory the full (T, N) path. metadata carries turnover_path (planned, assuming no drift), total_turnover, total_cost, smoothing_bias, the terminal weights and their annualized diagnostics, and solver counters.

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
def 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
    ----------
    returns:
        Asset return panel (rows = periods, columns = assets). Sample moments
        from this panel are held constant across the horizon unless overridden.
    horizon:
        Number of periods ``T`` to plan over.
    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.
    risk_aversion:
        Coefficient ``gamma`` on the per-period variance term. Larger tilts the
        whole path toward lower variance.
    costs:
        A :class:`~jaxfolio.types.TradingCosts` spec. ``None`` means frictionless,
        in which case every row of the path collapses onto the single-period
        mean-variance solution — pass costs to get path behavior.
    mu_path:
        Optional ``(T, N)`` expected-return term structure, overriding the
        constant sample mean. Use this to express a changing forecast.
    cov_path:
        Optional ``(T, N, N)`` covariance term structure, overriding the constant
        sample covariance.
    refine:
        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.
    config:
        Standard :class:`~jaxfolio.types.OptimizerConfig`. Note the default here
        uses ``tol=1e-6``; an explicitly passed config is honored as given.

    Returns
    -------
    PortfolioResult
        ``weights`` is the first path row, ``trajectory`` the full ``(T, N)``
        path. ``metadata`` carries ``turnover_path`` (**planned**, assuming no
        drift), ``total_turnover``, ``total_cost``, ``smoothing_bias``, the
        terminal weights and their annualized diagnostics, and solver counters.

    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)
    """
    config = config or OptimizerConfig(tol=1e-6)
    if config.constraints:
        # The row-wise projection kernels *can* take group caps — the feasible set
        # is still a Cartesian product over periods, so a per-period cap preserves
        # the structure this solver depends on. What is not settled is what a user
        # means by a cap here: per period, or on the horizon average? Rather than
        # guess, reject until the semantics are chosen. Never silently ignore.
        named = ", ".join(repr(c.name) for c in config.constraints)
        raise NotImplementedError(
            f"multi_period_mean_variance does not yet support named constraints ({named}). "
            "Per-period group caps are expressible — the projection is applied row-wise, so "
            "the feasible set stays a product over periods — but caps on a horizon aggregate "
            "are not, and which one a cap should mean is undecided. Use scalar "
            "`weight_bounds` for now, or apply the cap with a single-period optimizer."
        )
    if horizon < 1:
        raise ValueError(f"horizon must be at least 1, got {horizon}")
    costs = costs if costs is not None else TradingCosts()

    mu, cov, names, _ = moments(returns)
    n = len(names)

    if mu_path is None:
        mu_used = jnp.tile(jnp.asarray(mu, dtype=float), (horizon, 1))
    else:
        mu_used = jnp.asarray(mu_path, dtype=float)
        if mu_used.shape != (horizon, n):
            raise ValueError(
                f"mu_path shape {tuple(mu_used.shape)} does not match the problem; "
                f"expected ({horizon}, {n})"
            )

    if cov_path is None:
        cov_used = jnp.asarray(cov, dtype=float)
    else:
        cov_used = jnp.asarray(cov_path, dtype=float)
        if cov_used.shape != (horizon, n, n):
            raise ValueError(
                f"cov_path shape {tuple(cov_used.shape)} does not match the problem; "
                f"expected ({horizon}, {n}, {n})"
            )

    w_prev_arr = np.zeros(n) if w_prev is None else np.asarray(w_prev, dtype=float).reshape(-1)
    if w_prev_arr.shape != (n,):
        raise ValueError(f"w_prev has length {w_prev_arr.shape[0]}, expected {n} (one per asset)")

    cost_params = costs.as_params(n)
    W, info = solve_weight_path(
        mu_used,
        cov_used,
        w_prev_arr,
        risk_aversion=risk_aversion,
        c_lin=cost_params["c_lin"],
        c_quad=cost_params["c_quad"],
        l2_reg=config.l2_reg,
        trade_eps_rel=costs.smoothing,
        refine=refine,
        long_only=config.long_only,
        weight_bounds=config.bounds(),
        solver=config.solver_spec(),
        learning_rate=config.learning_rate,
        max_iter=config.max_iter,
        tol=config.tol,
    )

    path = np.asarray(W, dtype=float)
    # The terminal row is the long-run target; report its diagnostics too, since
    # row 0 sits near ``w_prev`` when costs bind and its Sharpe would otherwise
    # read as a regression against the myopic single-period result.
    terminal = finalize_result(
        path[-1], names, "terminal", mu=mu, cov=cov, risk_free=config.risk_free_rate
    )

    metadata = {
        "horizon": int(horizon),
        "risk_aversion": float(risk_aversion),
        "iterations": info["iterations"],
        "stage_iterations": list(info["stage_iterations"]),
        "stage_trade_eps": [float(e) for e in info["stage_trade_eps"]],
        "stage_objective_exact": [float(v) for v in info["stage_objective_exact"]],
        "selected_stage": info["selected_stage"],
        "trade_eps": info["trade_eps"],
        "residual": info["residual"],
        "converged": info["converged"],
        "costs_active": bool(costs.is_active()),
        "smoothing": float(costs.smoothing),
        "turnover_path": [float(x) for x in info["turnover_path"]],
        "total_turnover": info["total_turnover"],
        "total_cost": info["total_cost"],
        "objective_smoothed": info["objective_smoothed"],
        "objective_exact": info["objective_exact"],
        "smoothing_bias": info["smoothing_bias"],
        "terminal_weights": path[-1].tolist(),
        "terminal_expected_return": terminal.expected_return,
        "terminal_volatility": terminal.volatility,
        "terminal_sharpe": terminal.sharpe,
        "w_prev": w_prev_arr.tolist(),
        "mu_path_provided": mu_path is not None,
        "cov_path_provided": cov_path is not None,
    }

    return finalize_result(
        path[0],
        names,
        "Multi-Period Mean-Variance",
        mu=mu,
        cov=cov,
        risk_free=config.risk_free_rate,
        metadata=metadata,
        trajectory=path,
    )

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" by default, or "adamw", "sgd", "lion", ...) or a factory callable such as optax.adamw. See :func:jaxfolio.solvers.available_solvers.

'adam'
optimizer_options Mapping[str, Any] | None

Extra keyword arguments for the optax factory, e.g. {"weight_decay": 1e-4}.

None
Source code in src/jaxfolio/optimizers/learning.py
def deep_sharpe(
    returns,
    *,
    lookback: int = 60,
    hidden: tuple[int, ...] = (64, 32),
    epochs: int = 300,
    learning_rate: float = 1e-3,
    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
    ----------
    lookback:
        Length of the trailing return window fed to the policy.
    hidden:
        Hidden layer widths of the MLP.
    epochs:
        Number of full-batch gradient ascent steps.
    optimizer:
        Which optax optimizer trains the policy — a name (``"adam"`` by default,
        or ``"adamw"``, ``"sgd"``, ``"lion"``, ...) or a factory callable such as
        ``optax.adamw``. See :func:`jaxfolio.solvers.available_solvers`.
    optimizer_options:
        Extra keyword arguments for the optax factory, e.g.
        ``{"weight_decay": 1e-4}``.
    """
    spec = resolve_solver(optimizer, optimizer_options)
    if spec.kind != "optax":
        raise ValueError(
            "deep_sharpe trains an MLP policy, so it needs an optax optimizer "
            f"(e.g. 'adam', 'adamw', optax.sgd) — {optimizer!r} is not one. The 'spg' "
            "projected-gradient solver applies to the weight-vector optimizers instead."
        )

    mat, names = as_matrix(returns)
    t, n = mat.shape
    if t <= lookback + 1:
        raise ValueError("Not enough observations for the requested lookback")

    # Build (window -> next-period return) supervised windows.
    windows = jnp.stack([mat[i : i + lookback].reshape(-1) for i in range(t - lookback)])
    next_rets = mat[lookback:]  # (num_windows, n)

    key = jax.random.PRNGKey(seed)
    sizes = [lookback * n, *hidden, n]
    params = _init_mlp(key, sizes)

    def strategy_returns(params) -> Array:
        weights = jax.vmap(lambda x: _mlp_forward(params, x))(windows)
        return jnp.sum(weights * next_rets, axis=1)

    def neg_sharpe(params) -> Array:
        r = strategy_returns(params)
        mean = jnp.mean(r)
        std = jnp.std(r) + 1e-8
        return -(mean / std) * jnp.sqrt(_PPY)

    tx = build_optimizer(spec, learning_rate)
    opt_state = tx.init(params)
    loss_fn = jax.jit(jax.value_and_grad(neg_sharpe))

    @jax.jit
    def step(params, opt_state):
        loss, grads = loss_fn(params)
        updates, opt_state = tx.update(grads, opt_state, params)
        return optax.apply_updates(params, updates), opt_state, loss

    history = []
    for _ in range(epochs):
        params, opt_state, loss = step(params, opt_state)
        history.append(float(loss))

    # Allocation = policy on the most recent window.
    last_window = mat[-lookback:].reshape(-1)
    w = _mlp_forward(params, last_window)

    mu = mean_returns(mat)
    cov = sample_covariance(mat)
    w_np = np.asarray(w, dtype=float)
    ann_mu = float(portfolio_return(w, mu) * _PPY)
    ann_vol = float(portfolio_volatility(w, cov) * np.sqrt(_PPY))
    return PortfolioResult(
        weights=w_np,
        assets=names,
        method="Deep Sharpe (MLP policy)",
        expected_return=ann_mu,
        volatility=ann_vol,
        sharpe=ann_mu / ann_vol if ann_vol > 0 else None,
        metadata={
            "final_train_sharpe": -history[-1] if history else None,
            "epochs": epochs,
            "lookback": lookback,
            "optimizer": spec.name,
            "params": params,  # retained so the policy can be rolled forward
        },
    )

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
def 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.
    """
    mat, names = as_matrix(returns)
    t, n = mat.shape

    def update(w, r):
        gross = 1.0 + r
        port = jnp.dot(w, gross)
        grad = gross / port
        w_new = w * jnp.exp(eta * grad)
        w_new = w_new / jnp.sum(w_new)
        return w_new, port

    w0 = jnp.full(n, 1.0 / n)
    w_final, wealth_steps = jax.lax.scan(update, w0, mat)
    wealth = jnp.cumprod(wealth_steps)

    mu = mean_returns(mat)
    cov = sample_covariance(mat)
    ann_mu = float(portfolio_return(w_final, mu) * _PPY)
    ann_vol = float(portfolio_volatility(w_final, cov) * np.sqrt(_PPY))
    return PortfolioResult(
        weights=np.asarray(w_final, dtype=float),
        assets=names,
        method="Online EG Portfolio",
        expected_return=ann_mu,
        volatility=ann_vol,
        sharpe=ann_mu / ann_vol if ann_vol > 0 else None,
        metadata={
            "eta": eta,
            "final_wealth": float(wealth[-1]),
            "wealth_path": np.asarray(wealth, dtype=float).tolist(),
        },
    )

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 (PortfolioResult-compatible input, e.g. a DataFrame of periodic returns).

required
linkage_method str

SciPy hierarchical-clustering linkage method used to build the dendrogram. "single" (the default) matches López de Prado's original formulation; "average", "complete", or "ward" are also valid.

'single'

Returns:

Type Description
PortfolioResult

Weights plus metadata: the linkage matrix, the quasi-diagonal leaf order, and the ordered_assets names. A single-asset panel returns the trivial [1.0] allocation.

Source code in src/jaxfolio/optimizers/graph.py
def 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](validation.md)).

    Parameters
    ----------
    returns:
        Asset return panel (``PortfolioResult``-compatible input, e.g. a
        DataFrame of periodic returns).
    linkage_method:
        SciPy hierarchical-clustering linkage method used to build the
        dendrogram. ``"single"`` (the default) matches López de Prado's original
        formulation; ``"average"``, ``"complete"``, or ``"ward"`` are also valid.

    Returns
    -------
    PortfolioResult
        Weights plus metadata: the ``linkage`` matrix, the quasi-diagonal leaf
        ``order``, and the ``ordered_assets`` names. A single-asset panel returns
        the trivial ``[1.0]`` allocation.
    """
    mat, names = as_matrix(returns)
    mat_np = np.asarray(mat)
    if len(names) == 1:
        return _trivial_single_asset(names, mat_np, "Hierarchical Risk Parity")
    cov = np.asarray(sample_covariance(mat))
    corr = np.asarray(correlation_from_covariance(cov))

    dist = _corr_distance(corr)
    link = linkage(squareform(dist, checks=False), method=linkage_method)
    order = _quasi_diagonal_order(link)
    w = _recursive_bisection(cov, order)
    w = w / w.sum()

    ordered_names = [names[i] for i in order]
    return _finalize(
        w,
        names,
        "Hierarchical Risk Parity",
        mat_np,
        {"linkage": link.tolist(), "order": order, "ordered_assets": ordered_names},
    )

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
def 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.
    """
    from scipy.cluster.hierarchy import fcluster

    mat, names = as_matrix(returns)
    mat_np = np.asarray(mat)
    if len(names) == 1:
        return _trivial_single_asset(names, mat_np, "Hierarchical Equal Risk (HERC)")
    cov = np.asarray(sample_covariance(mat))
    corr = np.asarray(correlation_from_covariance(cov))
    n = len(names)
    n_clusters = min(n_clusters, n)

    dist = _corr_distance(corr)
    link = linkage(squareform(dist, checks=False), method=linkage_method)
    labels = fcluster(link, t=n_clusters, criterion="maxclust")

    weights = np.zeros(n)
    cluster_idx = {c: np.where(labels == c)[0].tolist() for c in np.unique(labels)}

    # Across-cluster: inverse cluster variance.
    cvars = {c: _cluster_variance(cov, idx) for c, idx in cluster_idx.items()}
    inv = {c: 1.0 / v for c, v in cvars.items()}
    total = sum(inv.values())
    for c, idx in cluster_idx.items():
        cluster_cap = inv[c] / total
        sub = cov[np.ix_(idx, idx)]
        wr = _inverse_variance_weights(sub)
        weights[idx] = cluster_cap * wr

    weights = weights / weights.sum()
    return _finalize(
        weights,
        names,
        "Hierarchical Equal Risk (HERC)",
        mat_np,
        {"labels": labels.tolist(), "n_clusters": int(n_clusters)},
    )

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
def 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``.
    """
    mat, names = as_matrix(returns)
    mat_np = np.asarray(mat)
    corr = np.asarray(correlation_from_covariance(sample_covariance(mat)))

    dist = _corr_distance(corr)
    mst = minimum_spanning_tree(dist).toarray()
    # Symmetrize the (upper-triangular) MST adjacency and compute degrees.
    adj = (mst + mst.T) > 0
    degree = adj.sum(axis=1).astype(float)
    degree = np.clip(degree, 1.0, None)

    inv = 1.0 / degree**alpha
    w = inv / inv.sum()

    # Eigenvector centrality on the MST (for diagnostics / plotting).
    try:
        vals, vecs = np.linalg.eigh(adj.astype(float))
        centrality = np.abs(vecs[:, -1])
    except np.linalg.LinAlgError:  # pragma: no cover
        centrality = degree

    return _finalize(
        w,
        names,
        "MST Centrality",
        mat_np,
        {
            "degree": degree.tolist(),
            "eigenvector_centrality": centrality.tolist(),
            "mst_adjacency": adj.astype(int).tolist(),
        },
    )

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 as solver_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, plain sgd) may run to max_iter or 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
def 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.
    """
    if constraints:
        from jaxfolio.constraints.compile import compile_constraints

        if assets is None:
            raise ValueError(
                "make_projection: named constraints need `assets` (the panel's asset names) "
                "to resolve names to columns"
            )
        compiled = compile_constraints(
            constraints, assets, long_only=long_only, weight_bounds=weight_bounds
        )
        fn, pparams = projection_for(compiled)
        return lambda w: fn(w, pparams)
    lo, hi = weight_bounds
    if long_only and lo <= 0.0 and hi >= 1.0:
        # Plain probability simplex is cheaper and exact.
        return lambda w: project_simplex(w, budget)
    return lambda w: project_box_budget(w, lo, hi, budget)

projection_for

projection_for(
    compiled, *, aux: bool = False, path: bool = False
) -> tuple[Any, tuple]

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
def projection_for(compiled, *, aux: bool = False, path: bool = False) -> tuple[Any, tuple]:
    """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.
    """
    return _PROJECTIONS[(compiled.kind, _shape_key(aux, path))], compiled.pparams()

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
def 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.
    """
    if constraints:
        from jaxfolio.constraints.compile import compile_constraints

        if assets is None:
            raise ValueError(
                "select_projection: named constraints need `assets` (the panel's asset "
                "names) to resolve names to columns"
            )
        compiled = compile_constraints(
            constraints, assets, long_only=long_only, weight_bounds=weight_bounds
        )
        return projection_for(compiled, aux=aux, path=path)
    _shape_key(aux, path)  # validate the aux/path combination
    lo, hi = weight_bounds
    simplex = long_only and lo <= 0.0 and hi >= 1.0
    if simplex:
        if path:
            return _proj_simplex_path, (1.0,)
        return (_proj_simplex_aux if aux else _proj_simplex), (1.0,)
    bounds = (float(lo), float(hi), 1.0)
    if path:
        return _proj_box_path, bounds
    return (_proj_box_aux if aux else _proj_box), bounds

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
def 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-7,
    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).
    """
    w, iters, resid = _solve_cached(
        objective,
        projection,
        obj_params,
        proj_params,
        w0,
        jnp.asarray(tol),
        solver=resolve_solver(solver, solver_options),
        learning_rate=learning_rate,
        max_iter=max_iter,
    )
    return w, {"iterations": iters, "residual": resid}

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
def solve_projected_gradient(
    objective: Objective,
    w0: Array,
    projection: Callable[[Array], Array],
    *,
    learning_rate: float | None = None,
    max_iter: int = 2000,
    tol: float = 1e-7,
    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"]``.
    """
    w, iters, resid = _run_solver(
        objective,
        projection,
        w0,
        solver=solver,
        learning_rate=learning_rate,
        max_iter=max_iter,
        tol=tol,
        solver_options=solver_options,
    )
    return w, {"iterations": iters, "residual": resid}

portfolio_return

portfolio_return(weights: Array, mu: Array) -> Array

Expected portfolio return w . mu.

Source code in src/jaxfolio/optimizers/base.py
@jax.jit
def portfolio_return(weights: Array, mu: Array) -> Array:
    """Expected portfolio return ``w . mu``."""
    return jnp.dot(weights, mu)

portfolio_variance

portfolio_variance(weights: Array, cov: Array) -> Array

Portfolio variance w' Sigma w.

Source code in src/jaxfolio/optimizers/base.py
@jax.jit
def portfolio_variance(weights: Array, cov: Array) -> Array:
    """Portfolio variance ``w' Sigma w``."""
    return weights @ cov @ weights

portfolio_volatility

portfolio_volatility(weights: Array, cov: Array) -> Array

Portfolio volatility sqrt(w' Sigma w).

Source code in src/jaxfolio/optimizers/base.py
@jax.jit
def portfolio_volatility(weights: Array, cov: Array) -> Array:
    """Portfolio volatility ``sqrt(w' Sigma w)``."""
    return jnp.sqrt(jnp.clip(portfolio_variance(weights, cov), 1e-18, None))

sharpe_ratio

sharpe_ratio(
    weights: Array,
    mu: Array,
    cov: Array,
    risk_free: float = 0.0,
) -> Array

Sharpe ratio of a weight vector given moments.

Source code in src/jaxfolio/optimizers/base.py
@jax.jit
def sharpe_ratio(weights: Array, mu: Array, cov: Array, risk_free: float = 0.0) -> Array:
    """Sharpe ratio of a weight vector given moments."""
    excess = portfolio_return(weights, mu) - risk_free
    return excess / portfolio_volatility(weights, cov)

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
terms() -> tuple[tuple[str | int, float], ...]

Return the row as ((asset, coefficient), ...).

Source code in src/jaxfolio/constraints/spec.py
@abc.abstractmethod
def terms(self) -> tuple[tuple[str | int, float], ...]:
    """Return the row as ``((asset, coefficient), ...)``."""
members
members() -> tuple[str | int, ...]

The assets this row touches — its support.

Source code in src/jaxfolio/constraints/spec.py
def members(self) -> tuple[str | int, ...]:
    """The assets this row touches — its support."""
    return tuple(asset for asset, _ in self.terms())

Budget dataclass

Budget(total: float = 1.0, name: str = 'budget')

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
width() -> int | None

Length of the per-asset bounds, or None if both are scalars.

Source code in src/jaxfolio/constraints/spec.py
def width(self) -> int | None:
    """Length of the per-asset bounds, or ``None`` if both are scalars."""
    for b in (self.lower, self.upper):
        if isinstance(b, tuple):
            return len(b)
    return None

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
def GroupFloor(  # noqa: N802 - a spec constructor, named like the class it returns
    name: str,
    assets: Sequence[str] | Sequence[int],
    min: float,  # noqa: A002 - mirrors GroupCap's public field name
) -> 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.
    """
    return GroupCap(name, tuple(assets), min=min)

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_assets and n_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 sum(w).

lower, upper

Effective per-asset bounds, length n_assets, after merging the implicit bounds with every :class:~jaxfolio.constraints.spec.Box.

row_names tuple[str, ...]

Names of the constraint rows, index-aligned with row_lower / row_upper and with the group ids in group_of.

row_lower, row_upper

Effective row limits, already clamped to what the box alone permits (sum of member bounds). Clamping keeps every value finite — no infinities enter the kernel — and makes "this row is slack" an exact test rather than a tolerance.

group_of tuple[int, ...]

Length n_assets; group_of[i] is the row index constraining asset i, or len(row_names) for the ungrouped sentinel.

specs tuple[Constraint, ...]

The original specifications, for reporting and :meth:perturb.

long_only, weight_bounds

The implicit bounds this set was compiled against. Retained so :meth:perturb can recompile faithfully — bounds that came from an OptimizerConfig rather than from a Box spec would otherwise be silently reset to the defaults.

members
members(name: str) -> tuple[str, ...]

Asset names constrained by row name.

Source code in src/jaxfolio/constraints/compile.py
def members(self, name: str) -> tuple[str, ...]:
    """Asset names constrained by row ``name``."""
    k = self.row_names.index(name)
    return tuple(a for a, g in zip(self.assets, self.group_of, strict=True) if g == k)
projection
projection(
    *, aux: bool = False, path: bool = False
) -> tuple[Any, tuple]

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
def projection(self, *, aux: bool = False, path: bool = False) -> tuple[Any, tuple]:
    """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.
    """
    from jaxfolio.optimizers.base import projection_for

    return projection_for(self, aux=aux, path=path)
pparams
pparams() -> tuple

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
def pparams(self) -> tuple:
    """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.
    """
    import jax.numpy as jnp

    if self.kind == KIND_SIMPLEX:
        return (self.budget,)
    if self.kind == KIND_BOX:
        return (self.lower[0], self.upper[0], self.budget)
    lower = jnp.asarray(self.lower)
    upper = jnp.asarray(self.upper)
    if self.kind == KIND_BOXVEC:
        return (lower, upper, jnp.asarray(self.budget))
    return (
        lower,
        upper,
        jnp.asarray(self.budget),
        jnp.asarray(self.group_of, dtype=jnp.int32),
        # The sentinel's limits are never read (its multiplier is pinned to
        # zero), but the arrays must be n_rows + 1 long to size the segments.
        jnp.asarray((*self.row_lower, 0.0)),
        jnp.asarray((*self.row_upper, 0.0)),
    )
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
def perturb(self, 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.
    """
    if name not in self.row_names and name != "budget":
        known = ", ".join(self.row_names) or "(none)"
        raise KeyError(f"no constraint named {name!r} in this problem; rows are: {known}")
    specs = [replace(s, **changes) if s.name == name else s for s in self.specs]
    out = compile_constraints(
        specs,
        self.assets,
        long_only=self.long_only,
        weight_bounds=self.weight_bounds,
    )
    if out.kind != self.kind or out.n_rows != self.n_rows:
        raise ValueError(
            f"perturbing {name!r} changed the projection kind "
            f"({self.kind} -> {out.kind}); that is a structural change, not a relaxation"
        )
    return out

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:jaxfolio.constraints.spec. Empty or None reproduces the pre-existing behaviour exactly (see the module docstring).

required
assets Sequence[str]

Asset names in panel order, as returned by :func:jaxfolio.results.moments.

required
long_only bool

The implicit bounds, normally config.long_only and config.bounds(). An unscoped :class:~jaxfolio.constraints.spec.Box replaces them; a scoped one intersects with them.

True
weight_bounds bool

The implicit bounds, normally config.long_only and config.bounds(). An unscoped :class:~jaxfolio.constraints.spec.Box replaces them; a scoped one intersects with them.

True

Raises:

Type Description
InfeasibleConstraints

The set admits no feasible portfolio. Exact, not heuristic.

ValueError

Two rows share an asset (see :mod:jaxfolio.constraints.structured), two constraints share a name, or more than one budget is given.

KeyError

A constraint names an asset that is not in the panel.

Source code in src/jaxfolio/constraints/compile.py
def 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
    ----------
    constraints:
        Specifications from :mod:`jaxfolio.constraints.spec`. Empty or ``None``
        reproduces the pre-existing behaviour exactly (see the module docstring).
    assets:
        Asset names in panel order, as returned by
        :func:`jaxfolio.results.moments`.
    long_only, weight_bounds:
        The implicit bounds, normally ``config.long_only`` and
        ``config.bounds()``. An unscoped :class:`~jaxfolio.constraints.spec.Box`
        replaces them; a scoped one intersects with them.

    Raises
    ------
    InfeasibleConstraints
        The set admits no feasible portfolio. Exact, not heuristic.
    ValueError
        Two rows share an asset (see :mod:`jaxfolio.constraints.structured`), two
        constraints share a name, or more than one budget is given.
    KeyError
        A constraint names an asset that is not in the panel.
    """
    names = tuple(str(a) for a in assets)
    n = len(names)
    if n == 0:
        raise ValueError("compile_constraints: the asset universe is empty")
    index = {a: i for i, a in enumerate(names)}
    specs = tuple(constraints or ())

    for spec in specs:
        if not isinstance(spec, Constraint):
            raise TypeError(
                "constraints must be jaxfolio.constraints specifications, got "
                f"{type(spec).__name__}"
            )
    seen: dict[str, Constraint] = {}
    for spec in specs:
        if spec.name in seen:
            raise ValueError(
                f"two constraints are both named {spec.name!r}; names must be unique so "
                "attribution can refer to them unambiguously"
            )
        seen[spec.name] = spec

    budgets = [s for s in specs if isinstance(s, Budget)]
    if len(budgets) > 1:
        raise ValueError(
            f"{len(budgets)} Budget constraints given ({', '.join(b.name for b in budgets)}); "
            "a portfolio has exactly one budget"
        )
    budget = float(budgets[0].total) if budgets else 1.0

    lower, upper = _merge_boxes(specs, names, index, n, long_only, weight_bounds)
    rows = [s for s in specs if isinstance(s, RowConstraint)]
    row_names, row_lower, row_upper, group_of = _build_rows(rows, index, n, lower, upper, names)

    _check_feasible_arrays(
        lower, upper, budget, row_names, row_lower, row_upper, group_of, names, rows
    )

    return CompiledConstraints(
        kind=_select_kind(lower, upper, row_names, long_only, budget),
        assets=names,
        budget=budget,
        lower=tuple(float(x) for x in lower),
        upper=tuple(float(x) for x in upper),
        row_names=tuple(row_names),
        row_lower=tuple(float(x) for x in row_lower),
        row_upper=tuple(float(x) for x in row_upper),
        group_of=tuple(int(g) for g in group_of),
        specs=specs,
        long_only=bool(long_only),
        weight_bounds=(float(weight_bounds[0]), float(weight_bounds[1])),
    )

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
def 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.
    """
    compile_constraints(constraints, assets, long_only=long_only, weight_bounds=weight_bounds)

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 lam on sum(w) = budget. Signed.

rows Array

(n_seg,) multipliers theta on the named rows — positive where a cap binds, negative where a floor binds, exactly zero where the row is slack. The last entry is the ungrouped sentinel and is always zero.

bounds Array

(n,) multipliers beta on the per-asset box — negative at a lower bound, positive at an upper bound, zero in the interior. This is the sign that makes the stationarity identity v - w == budget + rows[gid] + bounds hold as written: writing the box as mu_lo >= 0 on lower - w and mu_hi >= 0 on w - upper, beta = mu_hi - mu_lo. Note the reduced cost of the original portfolio problem carries the opposite sign (positive at a lower bound), because it equals -beta / step; see :mod:jaxfolio.attribution.

identified Array

(n_seg,) mask: is rows[k] actually pinned by the data? A row's multiplier is identified only when at least one of its members is strictly interior to its box — that member's stationarity equation is the one that separates theta_k from beta_i. When every member sits on a bound, the split of the shadow price between the row and the box bounds is genuinely not determined: any split summing to the same reduced cost is an equally valid KKT certificate. This kernel then reports the minimum-norm choice (theta = 0, everything attributed to the box), which is the reading users mean, but a different QP solver will legitimately report a different split. Verified against CVXPY: every identified row agrees to ~4e-9, while unidentified rows disagree by as much as 0.37.

budget_identified Array

Is budget pinned by the data? Only if some coordinate is strictly interior and free of a binding row — such a coordinate satisfies v_i - w_i == lam on its own. If every interior coordinate lies inside a binding row, stationarity determines only the sum lam + theta_k, so the returned budget is one admissible value among an interval rather than the shadow price of capital. Callers reporting a budget shadow price must check this first.

feasibility_residual Array

|sum(w) - budget| left after the bisection and the free-set correction. At the float precision floor when the problem is feasible; non-zero only if it is not.

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
def 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)``.
    """
    return project_box_budget_duals(v, lower, upper, budget, max_iter=max_iter).weights

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
def 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.
    """
    lo_tau = jnp.min(v - upper) - 1.0
    hi_tau = jnp.max(v - lower) + 1.0

    def body(_, bounds):
        lo, hi = bounds
        mid = 0.5 * (lo + hi)
        too_big = jnp.sum(jnp.clip(v - mid, lower, upper)) > budget
        return (jnp.where(too_big, mid, lo), jnp.where(too_big, hi, mid))

    lo_tau, hi_tau = jax.lax.fori_loop(0, max_iter, body, (lo_tau, hi_tau))
    lam = 0.5 * (lo_tau + hi_tau)
    x = v - lam
    w = jnp.clip(x, lower, upper)
    w, resid = _absorb_residual(w, lower, upper, budget, free=jnp.ones_like(w, dtype=bool))
    # beta_i = x_i - w_i: negative where the lower bound binds, positive at the
    # upper bound, zero in the interior. Recomputed from the final w so it stays
    # consistent with the returned primal point.
    beta = jnp.where(_interior(w, lower, upper), 0.0, x - w)
    return ProjectionDuals(
        weights=w,
        budget=lam,
        rows=jnp.zeros(1, dtype=w.dtype),
        bounds=beta,
        identified=jnp.zeros(1, dtype=bool),
        # With no rows, any strictly-interior coordinate pins lam on its own.
        budget_identified=jnp.any(_interior(w, lower, upper)),
        feasibility_residual=resid,
    )

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

(n,) point to project.

required
lower Array

(n,) per-asset bounds.

required
upper Array

(n,) per-asset bounds.

required
budget Array

Scalar; sum(w) == budget.

required
gid Array

(n,) int32 segment id per asset. Index n_seg - 1 is the ungrouped sentinel: assets assigned to it are subject to no row, and its multiplier is pinned to zero. Always allocated, possibly empty.

required
g_lower Array

(n_seg,) row bounds. The sentinel's entries are ignored.

required
g_upper Array

(n_seg,) row bounds. The sentinel's entries are ignored.

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
def 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
    ----------
    v:
        ``(n,)`` point to project.
    lower, upper:
        ``(n,)`` per-asset bounds.
    budget:
        Scalar; ``sum(w) == budget``.
    gid:
        ``(n,)`` int32 segment id per asset. Index ``n_seg - 1`` is the
        **ungrouped sentinel**: assets assigned to it are subject to no row, and
        its multiplier is pinned to zero. Always allocated, possibly empty.
    g_lower, g_upper:
        ``(n_seg,)`` row bounds. The sentinel's entries are ignored.

    Returns
    -------
    :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.
    """
    n_seg = g_lower.shape[0]
    active = jnp.arange(n_seg) < n_seg - 1

    def sums(lam):
        x = v - lam
        free = jnp.clip(x, lower, upper)
        freesum = jax.ops.segment_sum(free, gid, num_segments=n_seg)
        target = jnp.where(active, jnp.clip(freesum, g_lower, g_upper), freesum)
        theta = _solve_theta(x, lower, upper, gid, target, active, n_seg, inner_iter)
        return x, freesum, target, theta

    def total(lam):
        _x, _freesum, target, _theta = sums(lam)
        return jnp.sum(target)

    lo = jnp.min(v - upper) - 1.0
    hi = jnp.max(v - lower) + 1.0

    def body(_, bounds):
        lo, hi = bounds
        mid = 0.5 * (lo + hi)
        too_big = total(mid) > budget
        return (jnp.where(too_big, mid, lo), jnp.where(too_big, hi, mid))

    lo, hi = jax.lax.fori_loop(0, outer_iter, body, (lo, hi))
    lam = 0.5 * (lo + hi)

    x, freesum, target, theta = sums(lam)
    # Exact test: clip() returns freesum unchanged when the row is satisfied, so
    # equality here means "not binding" and pins theta to zero, discarding the
    # arbitrary value a saturated (degenerate) bracket would have produced.
    slack = jnp.logical_or(~active, target == freesum)
    theta = jnp.where(slack, 0.0, theta)

    shifted = x - theta[gid]
    w = jnp.clip(shifted, lower, upper)
    w, resid = _absorb_residual(w, lower, upper, budget, free=slack[gid])
    beta = jnp.where(_interior(w, lower, upper), 0.0, shifted - w)
    # A row's multiplier is pinned by the data only if some member is strictly
    # interior to its box: that member's stationarity equation is the one that
    # separates theta_k from beta. With every member on a bound, only the sum
    # theta_k + beta_i is determined and the split is arbitrary.
    interior = _interior(w, lower, upper)
    identified = jnp.logical_and(
        active, jax.ops.segment_sum(interior.astype(w.dtype), gid, num_segments=n_seg) > 0
    )
    return ProjectionDuals(
        weights=w,
        budget=lam,
        rows=theta,
        bounds=beta,
        identified=identified,
        # lam is pinned alone only by an interior coordinate whose row is slack;
        # inside a binding row, stationarity fixes only lam + theta_k.
        budget_identified=jnp.any(jnp.logical_and(interior, slack[gid])),
        feasibility_residual=resid,
    )

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
def 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.
    """
    return project_grouped_duals(
        v,
        lower,
        upper,
        budget,
        gid,
        g_lower,
        g_upper,
        outer_iter=outer_iter,
        inner_iter=inner_iter,
    ).weights

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:jaxfolio.constraints.compile_constraints.

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:jaxfolio.constraints.structured.ProjectionDuals.

kkt_residual, gradient_norm, step, step_check

Convergence evidence. kkt_residual is ||d|| where d is the feasible-descent part of the Moreau split; it is a genuine KKT measure for every solver family, including the optax ones whose own residual is only a weight-update norm. step_check re-probes at a tenth of step and reports the disagreement relative to ||grad f||; a large value means the probe left the region where the projection's active set is constant, so the multipliers were read off the wrong face.

aux_stationarity float | None

For packed [w, tau] problems (CVaR), |d f / d tau| at the solution — the auxiliary variable is unconstrained, so its whole gradient is "descent the constraints could not absorb".

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. sense is "minimize" or "maximize" for the objective as the user names it, which sets the reported sign of every shadow price.

stationarity property
stationarity: float

||d|| / ||grad f|| — the scale-free measure the quality gate uses.

summary
summary() -> dict[str, Any]

A small JSON-serializable digest for PortfolioResult.metadata.

Source code in src/jaxfolio/attribution.py
def summary(self) -> dict[str, Any]:
    """A small JSON-serializable digest for ``PortfolioResult.metadata``."""
    binding = [
        name
        for name, mult, ident in zip(
            self.row_names, self.row_multipliers, self.row_identified, strict=True
        )
        if ident and mult != 0.0
    ]
    return {
        "kkt_residual": float(self.kkt_residual),
        "stationarity": self.stationarity,
        "dual_quality": _grade(self),
        "n_binding_constraints": len(binding),
        "binding_constraints": binding,
    }

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, ...]

Constraints that are active and moving the optimum.

Source code in src/jaxfolio/attribution.py
def binding(self) -> tuple[ConstraintDual, ...]:
    """Constraints that are active *and* moving the optimum."""
    return tuple(c for c in self.constraints if c.status == "binding")
for_asset
for_asset(asset: str) -> AssetAttribution

Attribution for one asset by name.

Source code in src/jaxfolio/attribution.py
def for_asset(self, asset: str) -> AssetAttribution:
    """Attribution for one asset by name."""
    try:
        return self.assets_detail[self._index[asset]]
    except KeyError:
        raise KeyError(f"{asset!r} is not in this portfolio") from None
to_frame
to_frame() -> DataFrame

Per-asset attribution as a Polars frame with an asset column.

Source code in src/jaxfolio/attribution.py
def to_frame(self) -> pl.DataFrame:
    """Per-asset attribution as a Polars frame with an ``asset`` column."""
    import polars as pl

    rows = []
    for a in self.assets_detail:
        rows.append(
            {
                "asset": a.asset,
                "weight": a.weight,
                "status": a.status,
                "reduced_cost": np.nan if a.reduced_cost is None else a.reduced_cost,
                "intrinsic_cost": np.nan if a.intrinsic_cost is None else a.intrinsic_cost,
                "imposed_total": float(sum(v for _, v in a.imposed)),
                "binding": a.primary,
                "binding_kind": a.primary_kind,
                "shadow_price": (
                    np.nan if a.primary_shadow_price is None else a.primary_shadow_price
                ),
                "confidence": a.confidence,
            }
        )
    return pl.DataFrame(rows)
constraints_frame
constraints_frame() -> DataFrame

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
def constraints_frame(self) -> pl.DataFrame:
    """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.
    """
    import polars as pl

    rows = [
        {
            "name": c.name,
            "kind": c.kind,
            "activity": c.activity,
            "lower": c.lower,
            "upper": c.upper,
            "slack": c.slack,
            "multiplier": np.nan if c.multiplier is None else c.multiplier,
            "shadow_price": np.nan if c.shadow_price is None else c.shadow_price,
            "status": c.status,
            "n_members": len(c.members),
        }
        for c in self.constraints
    ]
    return pl.DataFrame(rows)
explain_text
explain_text(*, top_n: int = 10) -> str

A readable rendering of the report.

Source code in src/jaxfolio/attribution.py
def explain_text(self, *, top_n: int = 10) -> str:
    """A readable rendering of the report."""
    out = [f"Constraint attribution — {self.method} ({len(self.assets)} assets)"]
    if not self.available:
        out.append(f"  unavailable: {self.reason}")
        return "\n".join(out)
    line = f"  quality: {self.quality}"
    if self.stationarity is not None:
        line += f"  ·  stationarity {self.stationarity:.2e}"
    out.append(line)
    if self.budget_shadow_price is not None:
        out.append(f"  cost of capital (budget): {self.budget_shadow_price:+.5f} per unit")
    elif self.budget_note:
        out.append(f"  cost of capital (budget): n/a — {self.budget_note}")
    for w in self.warnings:
        out.append(f"  ! {w}")

    ranked = sorted(self.assets_detail, key=lambda a: (a.status == "interior", -abs(a.weight)))
    for a in ranked[:top_n]:
        out.append("")
        tag = "" if a.status == "interior" else f"   [{a.status.replace('_', ' ')}]"
        out.append(f"Asset {a.asset}: {a.weight:.2%}{tag}")
        if a.reason:
            out.append(f"  {a.reason}")
            continue
        if a.primary is None:
            out.append("  cause: ambiguous — several constraints are tied")
        elif a.primary == "own":
            out.append(f"  cause: own merits ({a.primary_kind})")
        else:
            sp = a.primary_shadow_price
            shown = "n/a" if sp is None else f"{sp:+.5f}"
            out.append(f"  binding: {a.primary} ({a.primary_kind})   shadow price {shown}")
        if a.intrinsic_cost is not None and a.imposed:
            charged = "  ·  ".join(f"charged by {n} {v:+.5f}" for n, v in a.imposed)
            out.append(
                f"  own merit {a.intrinsic_cost:+.5f}  ·  {charged}"
                f"  ·  net {a.reduced_cost:+.5f}"
            )
    return "\n".join(out)
to_table
to_table(
    *, max_assets: int | None = None, notes: bool = True
) -> str

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 max_assets asset rows, ordered so that the assets sitting on a bound — the ones a constraint may be responsible for — come first. None (the default) prints every asset: a report that silently drops rows is worse than a long one.

None
notes bool

Include the trailing notes block (units caveat, quality reason, warnings).

True

Examples:

>>> print(jf.explain(result).to_table())
Source code in src/jaxfolio/attribution.py
def to_table(self, *, max_assets: int | None = None, notes: bool = True) -> str:
    """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
    ----------
    max_assets:
        Show only the first ``max_assets`` asset rows, ordered so that the assets
        sitting on a bound — the ones a constraint may be responsible for — come
        first. ``None`` (the default) prints every asset: a report that silently
        drops rows is worse than a long one.
    notes:
        Include the trailing notes block (units caveat, quality reason, warnings).

    Examples
    --------
    >>> print(jf.explain(result).to_table())  # doctest: +SKIP
    """
    rule = "=" * 78
    out = [rule, f"CONSTRAINT ATTRIBUTION -- {_ascii(self.method)}", rule]

    fields = [("assets", str(len(self.assets)))]
    if self.objective:
        fields.append(("objective", f"{_ascii(self.objective)} ({self.sense})"))
    fields.append(("dual quality", self.quality))
    if self.stationarity is not None:
        fields.append(("stationarity", f"{self.stationarity:.2e}"))
    if self.kkt_residual is not None:
        fields.append(("KKT residual", f"{self.kkt_residual:.2e}"))
    if self.aux_stationarity is not None:
        fields.append(("aux stationarity", f"{self.aux_stationarity:.2e}"))
    extra_notes = []
    if self.budget_shadow_price is not None:
        fields.append(("cost of capital", f"{self.budget_shadow_price:+.3e} per unit"))
    elif self.budget_note:
        # The note is a sentence, not a value — it goes to NOTES where it can wrap,
        # rather than pushing the header block out to 150 columns.
        fields.append(("cost of capital", f"{_MISSING} (see notes)"))
        extra_notes.append(f"cost of capital: {self.budget_note}")
    if self.available:
        binding = [c.name for c in self.binding()]
        summary = f"{len(binding)} of {len(self.constraints)}"
        fields.append(
            ("binding rows", f"{summary}: {', '.join(binding)}" if binding else summary)
        )
    out += _render_fields(fields)

    if not self.available:
        out += ["", *_wrap(f"unavailable: {self.reason}")]

    if self.constraints:
        out += ["", "CONSTRAINTS"]
        out += _render_table(
            [
                "name",
                "kind",
                "activity",
                "bound",
                "slack",
                "multiplier",
                "shadow price",
                "status",
            ],
            [
                [
                    c.name,
                    c.kind,
                    _cell_pct(c.activity),
                    _cell_bound(c.lower, c.upper),
                    _cell_pct(c.slack),
                    _cell_num(c.multiplier),
                    _cell_num(c.shadow_price),
                    c.status,
                ]
                for c in self.constraints
            ],
            "<<>><>><",
        )

    ranked = sorted(self.assets_detail, key=lambda a: (a.status == "interior", -abs(a.weight)))
    shown = ranked if max_assets is None else ranked[:max_assets]
    if shown:
        out += ["", "ASSETS"]
        out += _render_table(
            ["asset", "weight", "status", "own merit", "imposed", "net", "cause", "confidence"],
            [
                [
                    a.asset,
                    _cell_pct(a.weight),
                    a.status.replace("_", " "),
                    _cell_num(a.intrinsic_cost),
                    _cell_num(sum(v for _name, v in a.imposed)) if a.imposed else _MISSING,
                    _cell_num(a.reduced_cost),
                    _cause_cell(a),
                    a.confidence,
                ]
                for a in shown
            ],
            "<><>>><<",
        )
        if len(shown) < len(ranked):
            out.append(f"({len(ranked) - len(shown)} further asset(s) not shown)")

    if notes:
        lines = ["multipliers are per period, in the objective's own units (not annualized)"]
        lines += extra_notes
        if self.reason and self.available:  # an unavailable reason is already printed above
            lines.append(self.reason)
        # No l2_reg note of our own: `warnings` already carries that one, in more
        # detail. Two phrasings of one caveat read as two caveats.
        lines += list(self.warnings)
        out += ["", "NOTES"]
        for line in lines:
            out += _wrap(line, bullet="- ")

    return "\n".join(out)
to_dict
to_dict() -> dict[str, Any]

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
def to_dict(self) -> dict[str, Any]:
    """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.
    """
    return {
        "schema": "jaxfolio.constraint_report/1",
        "method": self.method,
        "objective": self.objective or None,
        "sense": self.sense,
        "available": self.available,
        "quality": self.quality,
        "reason": self.reason,
        "diagnostics": {
            "kkt_residual": _json_float(self.kkt_residual),
            "stationarity": _json_float(self.stationarity),
            "step_check": _json_float(self.step_check),
            "aux_stationarity": _json_float(self.aux_stationarity),
            "l2_reg": _json_float(self.l2_reg),
        },
        "budget": {
            "multiplier": _json_float(self.budget_multiplier),
            "shadow_price": _json_float(self.budget_shadow_price),
            "note": self.budget_note,
        },
        "constraints": [
            {
                "name": c.name,
                "kind": c.kind,
                "activity": _json_float(c.activity),
                "lower": _json_float(c.lower),
                "upper": _json_float(c.upper),
                "slack": _json_float(c.slack),
                "multiplier": _json_float(c.multiplier),
                "shadow_price": _json_float(c.shadow_price),
                "status": c.status,
                "members": list(c.members),
            }
            for c in self.constraints
        ],
        "assets": [
            {
                "asset": a.asset,
                "weight": _json_float(a.weight),
                "status": a.status,
                "reduced_cost": _json_float(a.reduced_cost),
                "intrinsic_cost": _json_float(a.intrinsic_cost),
                "imposed": [
                    {"constraint": name, "charge": _json_float(value)}
                    for name, value in a.imposed
                ],
                "cause": a.primary,
                "cause_kind": a.primary_kind,
                "cause_shadow_price": _json_float(a.primary_shadow_price),
                "confidence": a.confidence,
                "reason": a.reason,
            }
            for a in self.assets_detail
        ],
        "warnings": list(self.warnings),
    }
to_json
to_json(*, indent: int | None = 2) -> str

The report as a JSON string. indent=None for a single compact line.

Source code in src/jaxfolio/attribution.py
def to_json(self, *, indent: int | None = 2) -> str:
    """The report as a JSON string. ``indent=None`` for a single compact line."""
    return json.dumps(self.to_dict(), indent=indent, allow_nan=False)

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
def 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-7,
    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.
    """
    import jax
    import jax.numpy as jnp

    from jaxfolio.constraints.structured import (
        project_box_budget_duals,
        project_grouped_duals,
    )

    z = jnp.asarray(weights)
    grad = jax.grad(lambda x: objective(x, obj_params))(z)
    w = z[:-aux_dim] if aux_dim else z
    g = grad[:-aux_dim] if aux_dim else grad

    lower = jnp.asarray(compiled.lower)
    upper = jnp.asarray(compiled.upper)
    budget = jnp.asarray(compiled.budget)
    if compiled.kind == "grouped":
        gid = jnp.asarray(compiled.group_of, dtype=jnp.int32)
        g_lo = jnp.asarray((*compiled.row_lower, 0.0))
        g_hi = jnp.asarray((*compiled.row_upper, 0.0))

        def project(v):
            return project_grouped_duals(v, lower, upper, budget, gid, g_lo, g_hi)
    else:
        # Every non-grouped kind — including the simplex, whose feasible set is
        # exactly the box [0, budget] intersected with the budget hyperplane — is
        # served by the box kernel, so the caller sees one uniform payload.
        def project(v):
            return project_box_budget_duals(v, lower, upper, budget)

    # Probe at a displacement scaled to the problem, then again ten times smaller.
    # C is polyhedral, so d_s is constant for all small enough s; disagreement
    # between the two means the larger probe left that region and the multipliers
    # are being read off the wrong face.
    scale = jnp.maximum(jnp.max(jnp.abs(w)), 1e-8)
    gnorm_inf = jnp.maximum(jnp.max(jnp.abs(g)), 1e-30)
    step = _PROBE_FRAC * scale / gnorm_inf

    def descent(s):
        return (project(w - s * g).weights - w) / s

    d_main = descent(step)
    d_fine = descent(step * 0.1)
    # Normalize by the gradient, NOT by ||d_fine||. Both descents have the units of
    # g, and at a converged solution both are essentially zero — dividing by
    # ||d_fine|| there divides noise by noise and reports a meaningless 1.0. Against
    # ||g|| the measure stays interpretable at both ends: ~0 when the two probes
    # agree (including when both are at the float floor), and O(1) when the larger
    # step has left the region where the active set is constant.
    gnorm = jnp.linalg.norm(g)
    step_check = jnp.linalg.norm(d_main - d_fine) / (gnorm + 1e-30)

    duals = project(w - step * g)
    n_rows = compiled.n_rows
    rows = np.asarray(duals.rows)[:n_rows] / float(step) if n_rows else np.zeros(0)
    # rc_i = g_i + lambda + theta_{k(i)} = -beta_i / step. See the module docstring;
    # the sign flip is why `bounds` is negative at a lower bound while a reduced
    # cost is positive there.
    reduced = -np.asarray(duals.bounds) / float(step)

    return SolverDuals(
        weights=np.asarray(w, dtype=float),
        gradient=np.asarray(g, dtype=float),
        lower=np.asarray(compiled.lower, dtype=float),
        upper=np.asarray(compiled.upper, dtype=float),
        budget=float(compiled.budget),
        row_names=tuple(compiled.row_names),
        row_lower=tuple(compiled.row_lower),
        row_upper=tuple(compiled.row_upper),
        group_of=tuple(compiled.group_of),
        budget_multiplier=float(duals.budget) / float(step),
        row_multipliers=tuple(float(x) for x in rows),
        reduced_costs=reduced,
        row_identified=tuple(bool(x) for x in np.asarray(duals.identified)[:n_rows]),
        budget_identified=bool(duals.budget_identified),
        kkt_residual=float(jnp.linalg.norm(d_main)),
        gradient_norm=float(gnorm),
        step=float(step),
        step_check=float(step_check),
        aux_stationarity=float(jnp.abs(grad[-1])) if aux_dim else None,
        objective=objective_name,
        sense=sense,
        smooth=smooth,
        convex=convex,
        l2_reg=float(l2_reg),
        solver_kind=solver_kind,
        tol=float(tol),
    )

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 1e-6 scaled by the bound magnitudes.

None
dual_tol float | None

How large a multiplier counts as non-zero. Defaults to 1e-6 scaled by ||grad f||, so it survives rescaling the objective.

None
Source code in src/jaxfolio/attribution.py
def 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
    ----------
    prim_tol:
        How close to a bound counts as *on* it. Defaults to ``1e-6`` scaled by the
        bound magnitudes.
    dual_tol:
        How large a multiplier counts as non-zero. Defaults to ``1e-6`` scaled by
        ``||grad f||``, so it survives rescaling the objective.
    """
    duals: SolverDuals | None = getattr(result, "attribution", None)
    assets = tuple(result.assets)
    weights = np.asarray(result.weights, dtype=float)
    index = {a: i for i, a in enumerate(assets)}

    if duals is None:
        reason = _NO_DUALS_REASONS.get(
            result.method,
            f"{result.method} does not emit solver diagnostics — it is feasible by "
            "construction rather than optimal subject to constraints, so a zero weight "
            "is caused by the construction, not by a constraint",
        )
        return ConstraintReport(
            method=result.method,
            objective="",
            sense="minimize",
            assets=assets,
            weights=weights,
            available=False,
            quality="unavailable",
            reason=reason,
            stationarity=None,
            kkt_residual=None,
            step_check=None,
            budget_multiplier=None,
            budget_shadow_price=None,
            budget_note=None,
            constraints=(),
            assets_detail=tuple(
                AssetAttribution(
                    asset=a,
                    weight=float(weights[i]),
                    status="unknown",
                    reduced_cost=None,
                    intrinsic_cost=None,
                    imposed=(),
                    primary=None,
                    primary_kind=None,
                    primary_shadow_price=None,
                    confidence="unavailable",
                    reason=reason,
                )
                for i, a in enumerate(assets)
            ),
            _index=index,
        )

    quality = _grade(duals)
    sigma = 1.0 if duals.sense == "maximize" else -1.0
    trust = quality in ("exact", "approximate")

    bound_scale = max(float(np.max(np.abs(duals.upper))), float(np.max(np.abs(duals.lower))), 1e-12)
    ptol = prim_tol if prim_tol is not None else 1e-6 * bound_scale
    dtol = dual_tol if dual_tol is not None else 1e-6 * max(duals.gradient_norm, 1e-12)

    warnings = _collect_warnings(duals, quality)
    constraints = _row_duals(duals, assets, sigma, ptol, dtol, trust)
    detail = _asset_attributions(duals, assets, constraints, ptol, dtol, trust)

    budget_note = None
    budget_sp: float | None = None
    budget_mult: float | None = None
    if not trust:
        budget_note = "suppressed: " + quality
    elif not duals.budget_identified:
        budget_note = (
            "not identified — every interior asset lies inside a binding row, so "
            "stationarity pins only the sum of the budget and row multipliers"
        )
    elif _is_scale_invariant(duals):
        budget_note = (
            "not meaningful: the objective is scale-invariant (degree-0 homogeneous), "
            "so the optimum is unchanged by a change of budget"
        )
    else:
        budget_mult = duals.budget_multiplier
        budget_sp = sigma * duals.budget_multiplier

    return ConstraintReport(
        method=result.method,
        objective=duals.objective,
        sense=duals.sense,
        assets=assets,
        weights=weights,
        available=True,
        quality=quality,
        reason=None if trust else _quality_reason(duals, quality),
        stationarity=duals.stationarity,
        kkt_residual=duals.kkt_residual,
        step_check=duals.step_check,
        budget_multiplier=budget_mult,
        budget_shadow_price=budget_sp,
        budget_note=budget_note,
        constraints=constraints,
        assets_detail=detail,
        warnings=warnings,
        aux_stationarity=duals.aux_stationarity,
        l2_reg=duals.l2_reg,
        _index=index,
    )