Skip to content

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
) -> 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,
) -> 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 = config or OptimizerConfig()
    mu, cov, names, _ = _moments(returns)
    n = len(names)

    projection, pparams = select_projection(config.long_only, config.bounds())
    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"])},
    )

mean_variance

mean_variance(
    returns,
    risk_aversion: float = 1.0,
    config: OptimizerConfig | None = 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,
) -> 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 = config or OptimizerConfig()
    mu, cov, names, _ = _moments(returns)
    n = len(names)

    projection, pparams = select_projection(config.long_only, config.bounds())
    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"])},
    )

maximum_sharpe

maximum_sharpe(
    returns, config: OptimizerConfig | None = 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,
) -> 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 = config or OptimizerConfig()
    mu, cov, names, _ = _moments(returns)
    n = len(names)
    rf = config.risk_free_rate

    projection, pparams = select_projection(config.long_only, config.bounds())
    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"])},
    )

maximum_diversification

maximum_diversification(
    returns, config: OptimizerConfig | None = 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,
) -> 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 = config or OptimizerConfig()
    mu, cov, names, _ = _moments(returns)
    n = len(names)
    vol = jnp.sqrt(jnp.diag(cov))

    projection, pparams = select_projection(config.long_only, config.bounds())
    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"])},
    )

risk_parity

risk_parity(
    returns, config: OptimizerConfig | None = 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.

Source code in src/jaxfolio/optimizers/classical.py
def risk_parity(
    returns,
    config: OptimizerConfig | None = 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.
    """
    config = config or OptimizerConfig()
    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
) -> 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,
) -> 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 = config or OptimizerConfig()
    mu, cov, names, mat = _moments(returns)
    n = len(names)

    projection, pparams = select_projection(config.long_only, config.bounds())
    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"])},
    )

min_cvar

min_cvar(
    returns,
    alpha: float = 0.95,
    config: OptimizerConfig | None = 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 uses the Adam solver regardless of config.solver.

Source code in src/jaxfolio/optimizers/classical.py
def min_cvar(
    returns,
    alpha: float = 0.95,
    config: OptimizerConfig | None = 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 uses the Adam solver regardless of ``config.solver``.
    """
    config = config or OptimizerConfig()
    mu, cov, names, mat = _moments(returns)
    n = len(names)
    t = mat.shape[0]
    scale = 1.0 / ((1.0 - alpha) * t)

    projection, pparams = select_projection(config.long_only, config.bounds(), 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)])
    lr = 1e-2 if config.learning_rate is None else config.learning_rate
    z, info = solve_constrained(
        _cvar_objective,
        params,
        z0,
        projection,
        pparams,
        solver="adam",
        learning_rate=lr,
        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"])},
    )

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,
) -> 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,
) -> 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 = config or OptimizerConfig()
    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 = select_projection(config.long_only, config.bounds())
    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"]),
        },
    )

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

    optimizer = optax.adam(learning_rate)
    opt_state = optimizer.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 = optimizer.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,
            "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).

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)."""
    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 solvers 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)||.
  • "adam" — the classic fixed-step optax-Adam projected-gradient loop. Its smooth dynamics are preferable when differentiating through the optimizer to train an allocation policy, and it handles the non-smooth CVaR objective (with its free auxiliary variable) more gracefully than a single scalar BB 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.

make_projection

make_projection(
    long_only: bool,
    weight_bounds: tuple[float, float],
    budget: float = 1.0,
) -> Callable[[Array], Array]

Return the w -> w projection matching the requested constraint set.

Source code in src/jaxfolio/optimizers/base.py
def make_projection(
    long_only: bool,
    weight_bounds: tuple[float, float],
    budget: float = 1.0,
) -> Callable[[Array], Array]:
    """Return the ``w -> w`` projection matching the requested constraint set."""
    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)

select_projection

select_projection(
    long_only: bool,
    weight_bounds: tuple[float, float],
    *,
    aux: bool = False,
)

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). pparams is a tuple of Python floats — passed as a traced argument to the kernel.

Source code in src/jaxfolio/optimizers/base.py
def select_projection(long_only: bool, weight_bounds: tuple[float, float], *, aux: bool = False):
    """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). ``pparams`` is a
    tuple of Python floats — passed as a traced argument to the kernel.
    """
    lo, hi = weight_bounds
    simplex = long_only and lo <= 0.0 and hi >= 1.0
    if simplex:
        return (_proj_simplex_aux if aux else _proj_simplex), (1.0,)
    return (_proj_box_aux if aux else _proj_box), (float(lo), float(hi), 1.0)

solve_constrained

solve_constrained(
    objective,
    obj_params,
    w0: Array,
    projection,
    proj_params,
    *,
    solver: str = "spg",
    learning_rate: float | None = None,
    max_iter: int = 2000,
    tol: float = 1e-07,
) -> 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. Returns (weights, info) with info["iterations"].

Source code in src/jaxfolio/optimizers/base.py
def solve_constrained(
    objective,
    obj_params,
    w0: Array,
    projection,
    proj_params,
    *,
    solver: str = "spg",
    learning_rate: float | None = None,
    max_iter: int = 2000,
    tol: float = 1e-7,
) -> 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.
    Returns ``(weights, info)`` with ``info["iterations"]``.
    """
    w, iters = _solve_cached(
        objective,
        projection,
        obj_params,
        proj_params,
        w0,
        jnp.asarray(tol),
        solver=solver,
        learning_rate=learning_rate,
        max_iter=max_iter,
    )
    return w, {"iterations": iters}

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 = "spg",
) -> 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. Returns (weights, info).

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 = "spg",
) -> 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`. Returns ``(weights, info)``.
    """
    w, iters = _run_solver(
        objective,
        projection,
        w0,
        solver=solver,
        learning_rate=learning_rate,
        max_iter=max_iter,
        tol=tol,
    )
    return w, {"iterations": iters}

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)