Lesson 5.7.5 · 75 min

End-to-end portfolio optimization: differentiable layers and Sharpe loss

The classic recipe predicts returns and then optimizes the portfolio with those predictions. The end-to-end approach trains the prediction model on what matters in the end, the portfolio's Sharpe, by passing the gradient through the optimizer. This lesson builds that layer in PyTorch, trains it with a Sharpe loss and evaluates it in walk-forward against the simple portfolios of level 4, with the universe's truth to separate skill from luck.

Fig. — Realised Sharpe (2011-2025, ±2 standard errors) and true Sharpe of the weights of six portfolios on the level 4 universe: simple baselines, predict-then-optimize (PTO) and two end-to-end portfolios
Level 5 lessons
  1. 5.1 · ML fundamentals applied to finance

    1. 5.1.1 Why financial machine learning is different
    2. 5.1.2 Supervised, unsupervised and reinforcement learning; scikit-learn pipelines
    3. 5.1.3 Economic metrics for trading models: beyond accuracy
  2. 5.2 · Feature engineering

    1. 5.2.1 Price, volume and microstructure features
    2. 5.2.2 Point-in-time fundamentals, macro and alternative data
    3. 5.2.3 Fractional differentiation for features
    4. 5.2.4 Leakage-free normalization and cross-sectional rank
    5. 5.2.5 Feature importance: MDI, MDA, SFI and SHAP
  3. 5.3 · Labelling and sampling

    1. 5.3.1 Fixed-horizon labeling and its problems
    2. 5.3.2 Triple-barrier method: labels that mimic a trade
    3. 5.3.3 Meta-labeling: one model for the side and another for the size
    4. 5.3.4 Label uniqueness, overlap weights and the sequential bootstrap
    5. 5.3.5 Event-based sampling: the symmetric CUSUM filter
  4. 5.4 · Validation for financial series

    1. 5.4.1 Why random k-fold fails on financial data
    2. 5.4.2 Purged k-fold with embargo and CPCV for ML models
    3. 5.4.3 Walk-forward with retraining: windows and frequency
    4. 5.4.4 Backtesting ML signals with costs and Deflated Sharpe
  5. 5.5 · Classical models

    1. 5.5.1 Regularized regressions: Ridge, Lasso, Elastic Net and logistic
    2. 5.5.2 Decision trees, Random Forest and bagging
    3. 5.5.3 Gradient boosting: XGBoost, LightGBM and CatBoost
    4. 5.5.4 Optuna and the risk of overfitting the validation
    5. 5.5.5 Unsupervised learning: PCA, clustering and autoencoders
    6. 5.5.6 Learning to rank for stock selection
  6. 5.6 · Deep learning with Keras 3

    1. 5.6.1 Keras 3: one model, several compute engines
    2. 5.6.2 An MLP on a stock panel: regularize and compare
    3. 5.6.3 1D CNN, LSTM and GRU: windows, scaling and leaks
    4. 5.6.4 Callbacks, a Sharpe loss and the model in production
  7. 5.7 · Deep learning with PyTorch

    1. 5.7.1 PyTorch: tensors, autograd and the training loop
    2. 5.7.2 Time series architectures: TCN, N-BEATS, PatchTST and iTransformer
    3. 5.7.3 Probabilistic models: quantile loss, DeepAR and intervals
    4. 5.7.4 Graph neural networks for asset relationships
    5. 5.7.5 End-to-end portfolio optimization: differentiable layers and Sharpe loss
  8. 5.8 · Time series foundation models

    1. 5.8.1 Time series foundation models: pretrain once, forecast without training
    2. 5.8.2 Foundation model families: architecture, data and license
    3. 5.8.3 In practice: context, quantiles, covariates and fine-tuning
    4. 5.8.4 Foundation models in finance: what the evidence says
    5. 5.8.5 Lab: foundation models vs GARCH, HAR and LightGBM
  9. 5.9 · Reinforcement learning

    1. 5.9.1 MDP, Q-learning, PPO and Gymnasium environments
    2. 5.9.2 RL for execution, market making and dynamic hedging
    3. 5.9.3 Why reinforcement learning overfits in directional trading
  10. 5.10 · Interpretability, robustness and model risk

    1. 5.10.1 SHAP, partial dependence and the stability of feature importances
    2. 5.10.2 Data drift and concept drift: detecting them and what to do
    3. 5.10.3 Ensembles, signal combination and synthetic data

Predict then optimize, or learn the portfolio

Lessons 4.1.1 to 4.1.4 built portfolios in two steps: estimate expected returns and covariances, and pass those estimates to an optimizer. If the returns are predicted by a machine learning model, the recipe is the same: predict then optimize (PTO). The model is trained on the error of its predictions, and the optimizer takes no part in the training.

The problem is that the squared error of the prediction is not what you want to minimize. A portfolio does not care about getting right the return of an asset it is not going to buy; it cares a lot about the ranking among the assets competing for weight and about what happens in the tails. The end-to-end (E2E) approach, or decision-focused learning, puts the optimizer inside the model and trains on the final metric: the Sharpe of the resulting portfolio. For that, the gradient has to go through the optimizer, which must be a differentiable layer.

gradientFeaturesModel muOptimizerCovarianceWeightsReturnSharpe loss
Mermaid source
flowchart TB
  F[Features] --> M[Model mu]
  M --> O[Optimizer]
  S[Covariance] --> O
  O --> W[Weights]
  W --> P[Return]
  P --> L[Sharpe loss]
  L -. gradient .-> M

In PTO, the backward arrow comes out of a prediction error and does not go through the optimizer. Elmachtoub and Grigas (2017) formalized the general idea (Smart predict, then optimize); Zhang, Zohren and Roberts (2020) trained networks that produce weights with a softmax layer and a Sharpe loss; Butler and Kwon (2021) integrated the prediction into a mean-variance layer. The promise is reasonable. The question of this lesson is the usual one: whether it survives an honest walk-forward against simple baselines.

The universe, the features and the covariance

The universe is the level 4 one of 38 total return indices (universo4_*), without MONET, the money market asset: its excess over the risk-free rate is almost zero with a volatility of 0.2%, and a Sharpe loss with no other constraints can end up concentrated in an asset like that (question 5 of the self-assessment). That leaves 37.

At each month end, for each asset, three features standardized across assets (12-month momentum skipping the last month, last month's return and three-month volatility), the covariance of the last year and the next month's return in excess of the risk-free rate. The covariance is Ledoit-Wolf on the standardized returns, that is, shrinking the correlation and not the covariance: lesson 4.1.2 showed that the scaled identity target spoils minimum variance when volatilities range from 1% to 30%.

IN [01]python
import numpy as np
import pandas as pd
import torch
from sklearn.covariance import LedoitWolf

torch.manual_seed(0)
torch.use_deterministic_algorithms(True)
torch.set_default_dtype(torch.float64)

precios = pd.read_parquet("universo4_precios.parquet")
precios = precios.set_index("fecha").drop(columns="MONET")
rf = pd.read_parquet("universo4_factores_diarios.parquet")
rf = rf.set_index("fecha")["rf"]
cols = precios.columns.tolist()
r = precios.pct_change().iloc[1:]
ex = r.sub(rf.loc[r.index], axis=0)          # simple daily excess
logp = np.log(precios).iloc[1:]               # aligned with r
fin_mes = r.index.to_series().groupby(r.index.to_period("M")).max()
print(f"{len(cols)} assets | {r.index[0].date()} to "
      f"{r.index[-1].date()} | {len(fin_mes)} month ends")
OUT [01]Excess returns and month ends
37 assets | 2006-01-04 to 2025-12-31 | 240 month ends
IN [02]python
def features(i):
    """Features of each asset at the close of session i, standardized
    across assets: 12-1 momentum, 1-month return, 3-month vol."""
    f = pd.DataFrame({
        "mom_12_1": logp.iloc[i - 21] - logp.iloc[i - 252],
        "ret_1m": logp.iloc[i] - logp.iloc[i - 21],
        "vol_3m": ex.iloc[i - 62:i + 1].std() * np.sqrt(252)})
    return ((f - f.mean()) / f.std()).to_numpy()

def covarianza(i):
    """Ledoit-Wolf of the last year's correlation, monthly."""
    x = ex.iloc[i - 251:i + 1].to_numpy()
    sd = x.std(axis=0)
    corr = LedoitWolf().fit(x / sd).covariance_
    return corr * np.outer(sd, sd) * 21

F, S, R, meses = [], [], [], []
fines = fin_mes.to_numpy()
for d0, d1 in zip(fines[:-1], fines[1:]):
    i0, i1 = r.index.get_loc(d0), r.index.get_loc(d1)
    if i0 < 252:
        continue
    F.append(features(i0))
    S.append(covarianza(i0))
    tramo = r.index[i0 + 1:i1 + 1]
    R.append((1 + r.loc[tramo]).prod().to_numpy()
             - (1 + rf.loc[tramo]).prod())
    meses.append(pd.Timestamp(d0))
F, S, R = (torch.tensor(np.array(a)) for a in (F, S, R))
meses = pd.DatetimeIndex(meses)
print("F", tuple(F.shape), "| S", tuple(S.shape), "| R", tuple(R.shape))
print("decisions:", meses[0].date(), "to", meses[-1].date())
OUT [02]Features, Ledoit-Wolf covariance and next return
F (227, 37, 3) | S (227, 37, 37) | R (227, 37)
decisions: 2007-01-31 to 2025-11-28

Each decision is made at the close of the last day of the month with data up to that day, and its outcome is the next month's return. The first decision needs a year of history (January 2007); the last one, in November 2025, covers December.

A differentiable optimization layer

The layer solves, for some expected returns μ^\hat\mu that will come out of the model, the long-only mean-variance problem:

w⋆(μ^)=arg⁡min⁡w γ2 w′Σw−μ^′ws.t.1′w=1,  w≥0w^\star(\hat\mu) = \arg\min_{w} \ \tfrac{\gamma}{2}\, w'\Sigma w - \hat\mu' w \quad \text{s.t.} \quad \mathbf{1}'w = 1,\ \ w \ge 0

If there were only the budget constraint, the Karush-Kuhn-Tucker conditions would give a closed formula, differentiable as is: the minimum variance portfolio plus a tilt proportional to μ^\hat\mu,

w⋆=Σ−111′Σ−11+1γ(Σ−1μ^−Σ−11 1′Σ−1μ^1′Σ−11)w^\star = \frac{\Sigma^{-1}\mathbf{1}}{\mathbf{1}'\Sigma^{-1}\mathbf{1}} + \frac{1}{\gamma}\left(\Sigma^{-1}\hat\mu - \Sigma^{-1}\mathbf{1}\, \frac{\mathbf{1}'\Sigma^{-1}\hat\mu}{\mathbf{1}'\Sigma^{-1}\mathbf{1}}\right)

Before using it, it is worth looking at the portfolio it gives in this universe:

IN [03]python
def mv_presupuesto(mu, Sigma, gamma=5.0):
    """min gamma/2 w'Sw - mu'w with 1'w = 1 (no w >= 0): KKT."""
    uno = torch.ones(mu.shape[-1])
    s1 = torch.linalg.solve(Sigma, uno)
    smu = torch.linalg.solve(Sigma, mu)
    return s1 / s1.sum() + (smu - s1 * smu.sum() / s1.sum()) / gamma

k = 48                                       # January 2011
mu_ej = F[k] @ torch.tensor([0.0, 0.0, -0.2]) / 100
w_cf = mv_presupuesto(mu_ej, S[k])
print(f"{meses[k].date()}: sum {w_cf.sum():.3f} | gross "
      f"{w_cf.abs().sum():.2f} | min. {w_cf.min():.2f} | "
      f"max. {w_cf.max():.2f}")
top = pd.Series(w_cf.numpy(), cols).sort_values()
print(top.iloc[[0, 1, -2, -1]].round(2).to_string())
OUT [03]The closed-form solution without long-only
2011-01-31: sum 1.000 | gross 17.42 | min. -1.67 | max. 1.85
RF_T20    -1.67
RF_T710   -1.36
RF_T37     1.77
RF_T13     1.85

With a modest prediction (−0.2% per month for each standard deviation of volatility), the portfolio without the long-only constraint has a gross exposure of 17 times capital: longs of 1.85 and 1.77 in the 1-3 and 3-7 year bonds and shorts of 1.67 and 1.36 in the 20 and 7-10 year ones. It bets with a lot of leverage on the differences between nearly redundant assets. A Sharpe loss would exploit that freedom. The w≥0w \ge 0 constraint is the cheapest regularization, and with it there is no closed formula anymore.

The standard tool to differentiate a constrained convex problem is cvxpylayers (Agrawal et al., 2019), which differentiates the problem's optimality conditions. Version 1.2.0 (Apache-2.0) requires cvxpy, which is not in the course's ml environment; this is how it would be written:

IN [04]python
import cvxpy as cp
from cvxpylayers.torch import CvxpyLayer

n = 37
w = cp.Variable(n)
mu = cp.Parameter(n)
L = cp.Parameter((n, n))            # Sigma = L L' (Cholesky)
gamma = 5.0
objetivo = cp.Minimize(gamma / 2 * cp.sum_squares(L.T @ w) - mu @ w)
problema = cp.Problem(objetivo, [cp.sum(w) == 1, w >= 0])
assert problema.is_dpp()             # parameters multiplying w
capa_cvx = CvxpyLayer(problema, parameters=[mu, L], variables=[w])
(pesos,) = capa_cvx(mu_tch, torch.linalg.cholesky(S_tch))

The dependency-free alternative is to unroll an iterative algorithm: accelerated projected gradient (FISTA) on the objective, with an exact projection onto the set {w≥0,1′w=1}\{w \ge 0, \mathbf{1}'w = 1\} (sort, accumulate and cut). Each iteration is made of PyTorch operations, so autograd differentiates through the 30 iterations as it would through 30 layers of a network.

There is a numerical detail that decides whether the layer is usable. The variances of the universe range from the short bond (≈ 1.5% annual volatility) to emerging market equities (≈ 27%): the covariance is very badly conditioned, and projected gradient with step 1/(γλmax⁡(Σ))1/(\gamma\lambda_{\max}(\Sigma)) crawls in the directions of the low-volatility assets. The solution is to precondition with the diagonal D=diag⁡(Σ)D = \operatorname{diag}(\Sigma): the step is divided by each asset's variance (working in volatility units), the step size is set by the largest eigenvalue of the correlation (much better conditioned) and the projection is done in the DD-weighted norm, which has the same sorting solution as the Euclidean one: wi=max⁡(0, yi−τ/di)w_i = \max(0,\ y_i - \tau/d_i), with τ\tau the value that makes the weights sum to 1.

IN [05]python
def proyectar(y, d):
    """argmin sum d_i (w_i - y_i)^2 with w >= 0, sum(w) = 1, by rows:
    w_i = max(0, y_i - tau / d_i)."""
    b = y * d                                # breakpoints
    orden = torch.argsort(b, dim=-1, descending=True)
    ys, inv_d = y.gather(-1, orden), (1 / d).gather(-1, orden)
    tau_k = (ys.cumsum(-1) - 1) / inv_d.cumsum(-1)
    rho = (b.gather(-1, orden) > tau_k).sum(-1, keepdim=True)
    tau = tau_k.gather(-1, rho - 1)
    return torch.clamp(y - tau / d, min=0)

var = torch.diagonal(S, dim1=1, dim2=2)          # D of each month
corr = S / torch.sqrt(var[:, :, None] * var[:, None, :])
lam_corr = torch.linalg.eigvalsh(corr)[:, -1:]
print(f"median condition number: covariance "
      f"{torch.linalg.cond(S).median():.0f}, correlation "
      f"{torch.linalg.cond(corr).median():.0f}")

def capa(mu, meses_, gamma=5.0, iters=30):
    """argmin gamma/2 w'Sw - mu'w, w >= 0, 1'w = 1: FISTA
    preconditioned by the diagonal, with each month's S."""
    Sig, d = S[meses_], var[meses_]
    paso = 1 / (gamma * lam_corr[meses_])
    w = torch.full(mu.shape, 1 / mu.shape[-1])
    z, t = w, 1.0
    for _ in range(iters):
        grad = gamma * torch.einsum("bij,bj->bi", Sig, z) - mu
        w_n = proyectar(z - paso * grad / d, d)
        t_n = (1 + np.sqrt(1 + 4 * t * t)) / 2
        z = w_n + (t - 1) / t_n * (w_n - w)
        w, t = w_n, t_n
    return w

from scipy.optimize import minimize
n = len(cols)
for nombre, mu_ in (("minimum variance", torch.zeros(n)),
                    ("with the prediction", mu_ej)):
    ref = minimize(
        lambda x: 2.5 * x @ S[k].numpy() @ x - mu_.numpy() @ x,
        np.full(n, 1 / n), method="SLSQP", bounds=[(0, 1)] * n,
        constraints=[{"type": "eq", "fun": lambda x: x.sum() - 1}],
        options={"ftol": 1e-15, "maxiter": 1000}).x
    w_capa = capa(mu_[None], [k])[0].numpy()
    print(f"{nombre}: max. |w - SLSQP| = "
          f"{np.abs(w_capa - ref).max():.4f} | assets with weight: "
          f"{(w_capa > 1e-3).sum()} (SLSQP: {(ref > 1e-3).sum()})")
OUT [05]Unrolled long-only mean-variance layer
median condition number: covariance 23925, correlation 426
minimum variance: max. |w - SLSQP| = 0.0022 | assets with weight: 5 (SLSQP: 5)
with the prediction: max. |w - SLSQP| = 0.0000 | assets with weight: 1 (SLSQP: 1)

With 30 preconditioned iterations, the layer ends up thousandths of weight away from the SLSQP solution in both cases (without preconditioning, with 30 iterations the distance was tens of weight points). 30 will be used: the layer is a fixed, differentiable approximation of the optimizer, and it is the same in training and in evaluation.

The second check is the gradient. Autograd differentiates the unrolled algorithm; finite differences, the result. They should be similar, except when an asset enters or leaves the portfolio (the support changes and the function has a corner):

IN [06]python
def sharpe(w, Rm):
    """Monthly Sharpe of portfolio w (simple returns, sum of w * r)."""
    rp = (w * Rm).sum(-1)
    return rp.mean() / rp.std()

ent0 = np.arange(0, 46)                  # 2007-01 to 2010-10
def perdida(theta):
    w = capa(F[ent0] @ theta / 100, ent0)
    return -sharpe(w, R[ent0])

theta0 = torch.zeros(3, requires_grad=True)
perdida(theta0).backward()
h = 1e-4
fd = [((perdida(h * e) - perdida(-h * e)) / (2 * h)).item()
      for e in torch.eye(3)]
print("autograd:", theta0.grad.numpy().round(2))
print("finite diff.:", np.round(fd, 2))
OUT [06]Gradient of the layer: autograd against finite differences
autograd: [ 2.43 -0.6  11.48]
finite diff.: [ 2.72  0.23 10.61]

The two large components (momentum and volatility) agree in sign and approximate magnitude; the one for last month's return is small and does not even have the same sign. They do not match exactly because at θ=0\theta = 0 there are assets on the edge of the support (minimum variance has five assets with weight) and a finite difference of 10−410^{-4} crosses corners that the local derivative does not see: the loss is piecewise smooth. This is normal in layers with inequality constraints, and it is one of the reasons why training these layers is noisy.

The model and the Sharpe loss

The model is deliberately small: μ^i=θ′fi/100\hat\mu_i = \theta' f_i / 100, a linear combination of the three features with three parameters (the /100 puts μ^\hat\mu on a monthly return scale and leaves θ\theta of order 1). With θ=0\theta = 0 the layer returns the minimum variance portfolio; training decides how much to tilt and in which direction. The loss is the negative monthly Sharpe over the training months:

L(θ)=−rp‾sd⁡(rp),rp,t+1=w⋆ ⁣(μ^t(θ))′Rt+1\mathcal{L}(\theta) = -\frac{\overline{r_p}}{\operatorname{sd}(r_p)}, \qquad r_{p,t+1} = w^\star\!\left(\hat\mu_t(\theta)\right)' R_{t+1}

The portfolio returns are the sum of weights times the simple returns of each asset (with log returns, the Sharpe of a portfolio comes out understated: lesson 4.1.1).

IN [07]python
def entrenar_e2e(ent, epocas=150, lr=0.01, cada=None):
    torch.manual_seed(0)
    theta = torch.zeros(3, requires_grad=True)
    opt = torch.optim.Adam([theta], lr=lr)
    hist = {}
    for e in range(epocas + 1):
        opt.zero_grad()
        p_ = perdida_en(theta, ent)
        if cada and e % cada == 0:
            hist[e] = round(float(-p_.item() * np.sqrt(12)), 2)
        if e < epocas:
            p_.backward()
            opt.step()
    return theta.detach(), hist

def perdida_en(theta, ent):
    w = capa(F[ent] @ theta / 100, ent)
    return -sharpe(w, R[ent])

theta_ej, hist = entrenar_e2e(ent0, cada=30)
print("annualized in-sample Sharpe by epoch:", hist)
print("theta (mom_12_1, ret_1m, vol_3m):", theta_ej.numpy().round(3))
OUT [07]Training with the Sharpe loss (2007-2010)
annualized in-sample Sharpe by epoch: {0: 0.96, 30: 1.49, 60: 1.5, 90: 1.53, 120: 1.53, 150: 1.56}
theta (mom_12_1, ret_1m, vol_3m): [-0.025  0.115 -0.184]

In 150 epochs the in-sample Sharpe rises from 0.96 (minimum variance, epoch 0) to 1.56. The model learned to weight against volatility and in favor of last month's return. In 2007-2010 that describes well what happened: bonds and low-volatility assets were the safe haven of the crisis. Whether it is a rule or a memory of 46 months, the walk-forward will tell.

Walk-forward: six portfolios

Every three years (January 2011, 2014, 2017, 2020 and 2023) the model is retrained with all the months whose return already ended before the decision date: the last training month is the second to last, because the return of the last one ends exactly on that day (the gap of lesson 5.4.3). Six portfolios, all long-only and rebalanced every month:

PortfolioWhat it usesLearned parameters
1/Nnothing0
Inverse volatilityvolatilities from the covariance0
Minimum variancethe layer with μ^=0\hat\mu = 00
PTOthe layer with μ^\hat\mu from a regression of the returns (minus their monthly mean) on the features3, by least squares
E2E mean-variancethe layer with μ^=θ′f/100\hat\mu = \theta' f / 1003, by the Sharpe loss
E2E softmaxw=softmax⁡(θ′f)w = \operatorname{softmax}(\theta' f), without covariance (Zhang et al., 2020)3, by the Sharpe loss
IN [08]python
nombres = ["1/N", "inverse vol.", "min. variance", "PTO",
           "E2E mean-variance", "E2E softmax"]
W = {m: torch.zeros(len(meses), n) for m in nombres}
ventanas = []
for anyo in (2011, 2014, 2017, 2020, 2023):
    pru = np.flatnonzero((meses.year >= anyo) & (meses.year < anyo + 3))
    ent = np.arange(0, pru[0] - 1)       # one-month gap
    # PTO: least squares of the month's relative return
    X_ = F[ent].reshape(-1, 3).numpy()
    y_ = (R[ent] - R[ent].mean(1, keepdim=True)).reshape(-1).numpy()
    th_pto = torch.tensor(np.linalg.lstsq(X_, y_, rcond=None)[0])
    # E2E with the layer and E2E softmax
    th_e2e, _ = entrenar_e2e(ent)
    torch.manual_seed(0)
    th_sm = torch.zeros(3, requires_grad=True)
    opt = torch.optim.Adam([th_sm], lr=0.05)
    for _ in range(150):
        opt.zero_grad()
        (-sharpe(torch.softmax(F[ent] @ th_sm, -1), R[ent])).backward()
        opt.step()
    with torch.no_grad():
        cero = torch.zeros(len(pru), n)
        vi = 1 / torch.diagonal(S[pru], dim1=1, dim2=2).sqrt()
        W["1/N"][pru] = 1 / n
        W["inverse vol."][pru] = vi / vi.sum(1, keepdim=True)
        W["min. variance"][pru] = capa(cero, pru)
        W["PTO"][pru] = capa(F[pru] @ th_pto, pru)
        W["E2E mean-variance"][pru] = capa(F[pru] @ th_e2e / 100, pru)
        W["E2E softmax"][pru] = torch.softmax(F[pru] @ th_sm, -1)
        sh_ent = {
            "min. variance": sharpe(capa(torch.zeros(len(ent), n),
                                         ent), R[ent]),
            "E2E mean-variance": -perdida_en(th_e2e, ent)}
    ventanas.append({
        "trained until": meses[ent[-1]].strftime("%Y-%m"),
        "θ E2E": th_e2e.numpy().round(2).tolist(),
        "in-sample Sharpe E2E": float(sh_ent["E2E mean-variance"])
        * np.sqrt(12),
        "in-sample Sharpe min. var.": float(sh_ent["min. variance"])
        * np.sqrt(12)})
pd.DataFrame(ventanas, index=[2011, 2014, 2017, 2020, 2023]).round(2)
OUT [08]Walk-forward with retraining every three years
     trained until                 θ E2E  in-sample Sharpe E2E  in-sample Sharpe min. var.
2011       2010-11  [-0.02, 0.12, -0.18]                  1.63                        1.05
2014       2013-11    [0.0, 0.12, -0.18]                  0.89                        0.63
2017       2016-11   [-0.02, 0.09, -0.1]                  0.77                        0.61
2020       2019-11  [-0.05, 0.11, -0.12]                  0.65                        0.43
2023       2022-11  [0.01, -0.02, -0.02]                  0.20                        0.15

In every window the E2E has a higher in-sample Sharpe than minimum variance, which is the portfolio it starts from with θ=0\theta = 0. Its θ\theta almost always tells the same story: weight against volatility and in favor of the last month. The question is what happens in the three years following each training:

IN [09]python
def sharpe_anual(m, meses_):
    rp = (W[m][meses_] * R[meses_]).sum(-1)
    return float(rp.mean() / rp.std() * np.sqrt(12))

v = pd.DataFrame(ventanas, index=[2011, 2014, 2017, 2020, 2023])
for m in ("E2E mean-variance", "min. variance"):
    v[f"out-of-sample Sharpe {m}"] = [
        sharpe_anual(m, np.flatnonzero((meses.year >= a)
                                       & (meses.year < a + 3)))
        for a in v.index]
v.drop(columns=["trained until", "θ E2E"]).round(2).T
OUT [09]In-sample Sharpe against the following three years
                                        2011  2014  2017  2020  2023
in-sample Sharpe E2E                    1.63  0.89  0.77  0.65  0.20
in-sample Sharpe min. var.              1.05  0.63  0.61  0.43  0.15
out-of-sample Sharpe E2E mean-variance  0.30  0.66 -0.48 -0.84 -0.38
out-of-sample Sharpe min. variance      0.35  0.64 -0.75 -0.67  0.18
Fig. — Sharpe of the E2E mean-variance portfolio in its training window and in the following three years, against minimum variance in those same years

In sample, the E2E beats minimum variance in all five windows, by 0.05 to 0.58 Sharpe points. In the following three years it wins in one (2017-2019, −0.48 against −0.75), ties in another (2014-2016) and loses in three; in 2023-2025 it loses by more than half a point. The in-sample advantage does not predict the out-of-sample advantage, which is the practical definition of overfitting. In the last window, moreover, training barely moves away from θ=0\theta = 0: with 2022 in the sample (bonds and equities falling at the same time), no tilt improved the training Sharpe.

Out-of-sample results

For 2011-2025 (179 months): excess return, volatility, gross and net Sharpe (10 bp per unit of turnover, as in lesson 4.1.5), maximum drawdown, annual turnover, effective number of assets (1/∑wi21/\sum w_i^2) and the average true Sharpe of the weights chosen, w′μ/w′Σww'\mu/\sqrt{w'\Sigma w} with the published μ and Σ of the universe (universo4_verdad.csv and universo4_cov_verdad.csv, block total). The last one separates what each method chose from the luck it had afterwards, as in lesson 4.1.4.

IN [10]python
verdad = pd.read_csv("universo4_verdad.csv", index_col="activo")
mu_v = verdad.loc[cols, "mu_verdadero"].to_numpy()
cv = pd.read_csv("universo4_cov_verdad.csv")
cv = cv[cv["regimen"] == "total"].set_index("activo")
sig_v = cv.loc[cols, cols].to_numpy()

prueba = np.flatnonzero(meses.year >= 2011)
Rp = R[prueba].numpy()
filas = {}
for m in nombres:
    w = W[m][prueba].numpy()
    rp = (w * Rp).sum(1)
    rot = np.r_[0.0, np.abs(np.diff(w, axis=0)).sum(1)]
    neto = rp - 0.001 * rot
    curva = np.cumprod(1 + rp)
    filas[m] = {
        "excess %": 1200 * rp.mean(),
        "vol. %": 100 * np.sqrt(12) * rp.std(),
        "Sharpe": np.sqrt(12) * rp.mean() / rp.std(),
        "net Sharpe": np.sqrt(12) * neto.mean() / neto.std(),
        "max. drawdown %": 100 * (curva / np.maximum.accumulate(curva)
                                  - 1).min(),
        "turnover %": 1200 * rot.mean(),
        "effective n": np.mean(1 / (w ** 2).sum(1)),
        "true Sharpe": np.mean([x @ mu_v / np.sqrt(x @ sig_v @ x)
                                for x in w])}
tabla = pd.DataFrame(filas).T
print(f"{len(prueba)} months, {meses[prueba[0]].date()} to "
      f"{meses[prueba[-1]].date()}")
print(tabla.round(2).to_string())
OUT [10]Out-of-sample metrics and true Sharpe
179 months, 2011-01-31 to 2025-11-28
                   excess %  vol. %  Sharpe  net Sharpe  max. drawdown %  turnover %  effective n  true Sharpe
1/N                    6.54   13.89    0.47        0.47           -22.83        0.00        37.00         0.39
inverse vol.           3.15    7.68    0.41        0.41           -16.69       26.12        16.53         0.47
min. variance         -0.03    1.60   -0.02       -0.03            -7.44       16.60         1.07         0.39
PTO                    2.42    4.42    0.55        0.36           -12.47      833.17         1.65         0.31
E2E mean-variance     -0.24    1.80   -0.13       -0.23           -10.34      191.23         1.10         0.30
E2E softmax            0.38    3.11    0.12       -0.05           -12.80      531.52         3.73         0.39

Turnover is the first symptom: the three portfolios that learn something turn over between ten and fifty times more than the simple ones, and costs take their toll.

Fig. — Annual turnover against net-of-cost Sharpe (10 bp per unit of turnover) of the six portfolios, 2011-2025

The figure at the top puts the realized and the true Sharpe of each portfolio on the same scale. Four readings:

  • E2E mean-variance is the worst by true Sharpe (0.30) and also by realized Sharpe (−0.13; −0.23 net). It does not improve on the portfolio it starts from with θ=0\theta = 0, minimum variance (0.39 true), and it turns over ten times more (191% per year against 17%).
  • Minimum variance is almost a single asset: 1.07 effective assets, mostly the 1-3 year bond, as in lesson 4.1.4. Its realized Sharpe (−0.02) understates what it chose (0.39), because the universe's bonds returned less than their expectation in the sample.
  • PTO has the best realized Sharpe (0.55) and the second-worst true one (0.31). Its weights depend on predictions with very little signal, jump from one month to the next (833% turnover per year) and happened to be right in the period. It is the result a backtest rewards and the truth does not support.
  • Inverse volatility has the best true Sharpe (0.47) with 16.5 effective assets and 26% turnover: the simplest portfolio with a covariance beats everything that learns something. 1/N, with the highest realized Sharpe after PTO (0.47), has a true Sharpe of 0.39.

How noisy the comparison is

With TT years of monthly returns, the standard error of an annual Sharpe is about (1+S2/2)/T\sqrt{(1 + S^2/2)/T}. With 15 years:

IN [11]python
anyos = len(prueba) / 12
et = np.sqrt((1 + tabla["Sharpe"] ** 2 / 2) / anyos)
pd.DataFrame({"Sharpe": tabla["Sharpe"], "std. error": et,
              "true Sharpe": tabla["true Sharpe"]}).round(2)
OUT [11]Standard error of the realized Sharpe
                   Sharpe  std. error  true Sharpe
1/N                  0.47        0.27         0.39
inverse vol.         0.41        0.27         0.47
min. variance       -0.02        0.26         0.39
PTO                  0.55        0.28         0.31
E2E mean-variance   -0.13        0.26         0.30
E2E softmax          0.12        0.26         0.39

A standard error of 0.26 to 0.28 per portfolio, larger than almost all the distances between them. A backtest of a single history cannot rank these portfolios; the true Sharpe, which does not exist on real data, can. And six methods have been tried (and in a real case, many more variants of features, γ and epochs): any winner by realized Sharpe would also have to pass the Deflated Sharpe discount of lesson 5.4.4.

When end-to-end can work

The result does not condemn the idea; it says what it needs. The E2E has the same information as PTO and more freedom to use it, and freedom is paid for in estimation variance:

ConditionIn this lessonWhat would be needed
Many independent decisions37 assets, from 46 to 191 months per windowuniverses of hundreds of assets, daily or intraday data
A stable relationship between features and returnsthe scripted crises reward bonds in some windows and not in othersfeatures with a stable signal (lesson 5.4.3)
Costs and constraints inside the layerturnover is measured afterwards, not penalizedturnover penalty and per-asset limits in the layer's problem
Regularization toward the base portfoliononepenalize the distance to minimum variance, early stopping, ensembles
Honest validation of the procedurewalk-forward with a gapalso for choosing epochs, γ and features (without looking at the test)

The papers that find improvements with E2E (Butler and Kwon, 2021; Uysal et al., 2021 with risk budgets) usually reduce the model's freedom, not increase it: layers with more structure, predictions that only tilt a robust portfolio and losses that mix the prediction error with the decision error. Before any of them, the question of this lesson: does it improve on its own portfolio with θ=0\theta = 0, out of sample and after costs?

Exercises

  1. Train the E2E mean-variance on 2007-2010 for 450 epochs and measure every 75 epochs the Sharpe in sample and in 2011-2013 (without retraining). What happens to each one? Can you use the 2011-2013 curve to choose the number of epochs?
Solution
IN [12]python
pru0 = np.flatnonzero((meses.year >= 2011) & (meses.year < 2014))
torch.manual_seed(0)
theta = torch.zeros(3, requires_grad=True)
opt = torch.optim.Adam([theta], lr=0.01)
curva = {}
for e in range(451):
    opt.zero_grad()
    p_ = perdida_en(theta, ent0)
    if e % 75 == 0:
        with torch.no_grad():
            fuera = -perdida_en(theta, pru0)
        curva[e] = {"in sample": -p_.item() * np.sqrt(12),
                    "2011-2013": fuera.item() * np.sqrt(12)}
    if e < 450:
        p_.backward()
        opt.step()
pd.DataFrame(curva).T.round(2)
OUT [12]Sharpe in and out of sample by epoch
     in sample  2011-2013
0         0.96       0.35
75        1.51       0.30
150       1.56       0.31
225       1.61       0.31
300       1.65       0.35
375       1.68       0.36
450       1.69       0.37

The in-sample Sharpe keeps rising with the epochs (from 0.96 to 1.69) and the 2011-2013 one does not follow it: it moves between 0.30 and 0.37, around that of minimum variance at epoch 0 (0.35). The extra epochs only buy fit to 46 months. Choosing the number of epochs with the 2011-2013 curve (here, keeping the last one) would mean using the test to tune the model: the 2011-2013 result would no longer be out of sample. Early stopping needs a validation stretch inside the training period (the last months before the gap) and, even so, with 46 months that stretch is so short that its Sharpe is almost all noise.

  1. Recompute the net Sharpe of the table with costs of 5, 10, 25 and 50 bp per unit of turnover. From what cost does PTO stop paying off against minimum variance?
Solution
IN [13]python
sens = {}
for c in (5, 10, 25, 50):
    fila = {}
    for m in ("min. variance", "PTO", "E2E mean-variance",
              "inverse vol."):
        w = W[m][prueba].numpy()
        rot = np.r_[0.0, np.abs(np.diff(w, axis=0)).sum(1)]
        rn = (w * Rp).sum(1) - c / 1e4 * rot
        fila[m] = np.sqrt(12) * rn.mean() / rn.std()
    sens[f"{c} bp"] = fila
pd.DataFrame(sens).round(2)
OUT [13]Sensitivity to costs
                   5 bp  10 bp  25 bp  50 bp
min. variance     -0.02  -0.03  -0.04  -0.07
PTO                0.46   0.36   0.08  -0.39
E2E mean-variance -0.18  -0.23  -0.38  -0.61
inverse vol.       0.41   0.41   0.40   0.39

PTO stays above minimum variance up to 25 bp (0.08 against −0.04) and falls far below it at 50 bp (−0.39 against −0.07): its turnover (more than eight times capital per year) turns every basis point of cost into lost Sharpe. Inverse volatility barely moves (from 0.41 to 0.39). The comparison without costs systematically favors unstable methods, and that is why the net column of the table is the one to look at.

  1. The layer of this lesson fixes γ = 5 and leaves the scale of θ\theta free. Would anything change if γ were also trained? And if the budget constraint were removed and the Sharpe loss left to decide the size?
Solution

With the scale of θ\theta free, γ adds nothing: the portfolio depends on μ^/γ\hat\mu/\gamma, so multiplying θ\theta by two is equivalent to dividing γ by two. Training both would give an unidentified parameter (the optimizer would move them together without changing the loss). Removing the budget is worse: the Sharpe does not change when all the weights are multiplied by a constant, so the loss does not fix the size of the portfolio, and a portfolio that only has to be “the best in Sharpe” can degenerate (weights that grow without limit, or everything in the least volatile asset, like MONET). The layer's constraints (budget, long-only, limits) are what shape the problem; the loss only chooses within them.

Each answer is folded under its question

Self-assessment

  1. What changes in the end-to-end approach compared with predicting and then optimizing?

    • AThat no covariance is needed
    • BThat the optimizer is replaced by an unconstrained neural network
    • CThat more features are used
    • DThat the parameters of the prediction model are chosen by the portfolio's result (the Sharpe loss), propagating the gradient through the optimizer, and not by the prediction error
    Show answer

    Answer D. That the parameters of the prediction model are chosen by the portfolio's result (the Sharpe loss), propagating the gradient through the optimizer, and not by the prediction error

    In PTO the model minimizes the squared error of the returns and the optimizer is applied afterwards. In E2E the same optimization layer sits inside the computation graph and the model learns what improves the decision. It requires the optimizer to be differentiable.

  2. Why does the lesson not use the closed-form mean-variance solution with only the budget constraint, even though it is differentiable and exact?

    • ABecause without w ≥ 0 the solution takes huge short positions and leverage with 37 highly correlated assets (here, a gross exposure of 17 times capital), and a Sharpe loss would exploit that freedom
    • BBecause PyTorch cannot invert matrices
    • CBecause it has no gradient
    • DBecause it only works with two assets
    Show answer

    Answer A. Because without w ≥ 0 the solution takes huge short positions and leverage with 37 highly correlated assets (here, a gross exposure of 17 times capital), and a Sharpe loss would exploit that freedom

    The KKT conditions with only the budget give a closed formula, but with nearly redundant assets (the hedged and unhedged versions of an index) the optimal portfolio bets on their differences with a lot of leverage. The long-only constraint is the cheapest regularization, and it requires an iterative optimizer or cvxpylayers.

  3. Trained on 2007-2010, the E2E portfolio has an in-sample Sharpe of 1.63 against 1.05 for minimum variance; in 2011-2013, 0.30 against 0.35. What does it indicate?

    • AThat 2011-2013 was a bad period for any portfolio
    • BOverfitting of the decision loss: with 46 months, the layer learns to concentrate on what worked in the crisis of the training window
    • CThat it needs to be trained for more epochs
    • DThat the optimization layer has a bug
    Show answer

    Answer B. Overfitting of the decision loss: with 46 months, the layer learns to concentrate on what worked in the crisis of the training window

    The in-sample Sharpe measures how well the rule fits 46 specific months. The E2E optimizes that figure directly, so it inflates it more than a model trained on prediction error. Out of sample it does not improve on the portfolio it starts from; over 2011-2025 as a whole it ends up below it.

  4. What is the “true Sharpe” of the weights, $w'\mu / \sqrt{w'\Sigma w}$ with the published μ and Σ of the universe, for?

    • ATo replace the realized Sharpe in reports
    • BTo compute the costs
    • CTo separate what each method chose from the luck it had afterwards: with 15 years, the standard error of a realized Sharpe is about 0.26, more than many of the differences between methods
    • DTo train the layer
    Show answer

    Answer C. To separate what each method chose from the luck it had afterwards: with 15 years, the standard error of a realized Sharpe is about 0.26, more than many of the differences between methods

    On real data it does not exist. In the synthetic universe it does, and it shows that a high realized Sharpe can come from weights with a low expected Sharpe (and vice versa).

  5. The Sharpe loss is invariant to the scale of the portfolio. What would happen if the budget constraint were removed and a money market asset with almost no volatility were left in?

    • ANothing: the Sharpe already controls risk
    • BThe model could concentrate on the money market asset, which with a tiny volatility has an arbitrary in-sample Sharpe, or scale the weights without limit; the loss needs constraints that fix the scale and a universe without degenerate assets
    • CThe portfolio would always be the minimum variance one
    • DDiversification would improve
    Show answer

    Answer B. The model could concentrate on the money market asset, which with a tiny volatility has an arbitrary in-sample Sharpe, or scale the weights without limit; the loss needs constraints that fix the scale and a universe without degenerate assets

    That is why the lesson removes MONET from the universe and the layer imposes 1′w = 1 and w ≥ 0. The layer's constraints are part of the model, not an implementation detail.

Key points

  • PTO trains on prediction error and optimizes afterwards; E2E trains on the portfolio metric with the optimizer inside the computation graph. It needs a differentiable optimization layer.
  • Mean-variance with only the budget has a closed-form solution (KKT), but with 37 correlated assets it gives extreme leverage. With w ≥ 0, the layer is solved by unrolled projected gradient (here) or with cvxpylayers (not installed in the ml environment).
  • With a badly conditioned covariance, the projected gradient needs diagonal preconditioning: with 30 iterations it ends up thousandths of weight away from SLSQP. The autograd gradient resembles the finite-difference one except at the corners (support changes). Both checks come before training.
  • The Sharpe loss overfits: in sample, the E2E beats minimum variance in all five windows (1.63 against 1.05 in the first); out of sample (2011-2025), a Sharpe of −0.13 and −0.23 net, with a true Sharpe of 0.30, the worst of the six portfolios.
  • With 15 years, the standard error of a realized Sharpe is about 0.27: PTO has the best realized (0.55) and the second-worst true (0.31). The true Sharpe ranks them: inverse volatility 0.47, then 1/N, minimum variance and E2E softmax (0.39), PTO and E2E mean-variance.
  • What end-to-end needs to work: many independent decisions, a stable relationship, costs and constraints inside the layer, and regularization. With 37 assets and 18 years of months, they are not there.

Resources

Data