---
title: "5.7.5 End-to-end portfolio optimization"
description: "A differentiable optimization layer in PyTorch, a Sharpe loss and walk-forward against predict-then-optimize, minimum variance and 1/N."
url: "https://pythonparatrading.com/en/python-trading-course/level-5/end-to-end-portfolio-optimization/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-5/end-to-end-portfolio-optimization.png"
---

# 5.7.5 · 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.

- Course: [Level 5: Machine learning and deep learning (Keras/PyTorch)](https://pythonparatrading.com/en/python-trading-course/level-5/)
- Module: 5.7 · Deep learning with PyTorch
- Duration: 75 min
- Prerequisites: [5.7.1 · PyTorch: tensors, autograd and the training loop](https://pythonparatrading.com/en/python-trading-course/level-5/pytorch-tensors-autograd-training-loop/), [4.1.4 · Risk parity, minimum variance, HRP and HERC](https://pythonparatrading.com/en/python-trading-course/level-4/risk-parity-minimum-variance-hrp-herc/), [4.1.2 · Covariance estimation: shrinkage, factor models and denoising](https://pythonparatrading.com/en/python-trading-course/level-4/covariance-estimation-shrinkage-and-denoising/), [5.4.3 · Walk-forward with retraining: windows and frequency](https://pythonparatrading.com/en/python-trading-course/level-5/walk-forward-retraining-windows/)

## By the end of this lesson

- Explain the difference between predicting and then optimizing (PTO) and learning end to end (E2E) through a decision loss.
- Write a differentiable long-only, fully invested mean-variance layer (unrolled projected gradient) and check it against an optimizer and against finite differences.
- Train the model with a Sharpe loss and evaluate it in walk-forward with a gap between training and test, with costs and with the true Sharpe of the chosen weights.
- Recognize the symptoms of overfitting a decision loss: rising in-sample Sharpe, concentration, high turnover and falling true Sharpe.

## 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**.

```mermaid
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.

<aside class="callout" data-kind="aviso">

Synthetic level 4 data with the truth published (μ and Σ of each asset). They serve to measure what each method chose,
not to claim anything about real portfolios.

</aside>

## 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%.

```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")
```
```output
37 assets | 2006-01-04 to 2025-12-31 | 240 month ends
```

```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())
```
```output
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^\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^\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:

```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())
```
```output
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 \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:

```python skip
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 \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/(\gamma\lambda_{\max}(\Sigma))$ crawls in the directions of the low-volatility
assets. The solution is to **precondition** with the diagonal $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 $D$-weighted norm, which has the same
sorting solution as the Euclidean one: $w_i = \max(0,\ y_i - \tau/d_i)$, with $\tau$ the value that makes the weights
sum to 1.

```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()})")
```
```output
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):

```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))
```
```output
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 $\theta = 0$ there are
assets on the edge of the support (minimum variance has five assets with weight) and a finite difference of $10^{-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: $\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
$\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:

$$
\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).

```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))
```
```output
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:

| Portfolio | What it uses | Learned parameters |
|---|---|---|
| 1/N | nothing | 0 |
| Inverse volatility | volatilities from the covariance | 0 |
| Minimum variance | the layer with $\hat\mu = 0$ | 0 |
| PTO | the layer with $\hat\mu$ from a regression of the returns (minus their monthly mean) on the features | 3, by least squares |
| E2E mean-variance | the layer with $\hat\mu = \theta' f / 100$ | 3, by the Sharpe loss |
| E2E softmax | $w = \operatorname{softmax}(\theta' f)$, without covariance (Zhang et al., 2020) | 3, by the Sharpe loss |

```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)
```
```output
     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 $\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:

```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
```
```output
                                        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.* Five windows (testing from 2011, 2014, 2017, 2020 and 2023). In sample, E2E: 1.63, 0.89, 0.77, 0.65 and 0.20. In the following three years (highlighted): 0.30, 0.66, −0.48, −0.84 and −0.38. Minimum variance in those years: 0.35, 0.64, −0.75, −0.67 and 0.18. The in-sample edge does not carry over.

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 $\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/\sum w_i^2$) and the average **true Sharpe**
of the weights chosen, $w'\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.

```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())
```
```output
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.* Six points. 1/N: turnover 0% and net Sharpe 0.47. Inverse volatility: 26% and 0.41. Minimum variance: 17% and −0.03. E2E mean-variance (highlighted): 191% and −0.23. E2E softmax: 532% and −0.05. PTO: 833% and 0.36. The portfolios that learn something turn over ten to fifty times more than the simple ones.

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 $\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 $T$ years of monthly returns, the standard error of an annual Sharpe is about $\sqrt{(1 + S^2/2)/T}$. With 15
years:

```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)
```
```output
                   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.

<aside class="callout" data-kind="riesgo">

The Sharpe loss optimizes exactly the figure that is presented in a backtest. That is why it inflates the in-sample
result more than a model trained on prediction error, and why its in-sample result is worthless as evidence. The only
thing that counts is a walk-forward with a gap, with costs, against the portfolio the model reduces to with
$\theta = 0$.

</aside>

## 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:

| Condition | In this lesson | What would be needed |
|---|---|---|
| Many independent decisions | 37 assets, from 46 to 191 months per window | universes of hundreds of assets, daily or intraday data |
| A stable relationship between features and returns | the scripted crises reward bonds in some windows and not in others | features with a stable signal (lesson 5.4.3) |
| Costs and constraints inside the layer | turnover is measured afterwards, not penalized | turnover penalty and per-asset limits in the layer's problem |
| Regularization toward the base portfolio | none | penalize the distance to minimum variance, early stopping, ensembles |
| Honest validation of the procedure | walk-forward with a gap | also 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 $\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.**

```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)
```
```output
     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.

2. 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.**

```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)
```
```output
                   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.

3. 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.

## Self-assessment

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

   - A. That no covariance is needed
   - B. That the optimizer is replaced by an unconstrained neural network
   - C. That more features are used
   - 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

   *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?

   - 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
   - B. Because PyTorch cannot invert matrices
   - C. Because it has no gradient
   - D. Because it only works with two assets

   *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?

   - A. That 2011-2013 was a bad period for any portfolio
   - B. Overfitting of the decision loss: with 46 months, the layer learns to concentrate on what worked in the crisis of the training window
   - C. That it needs to be trained for more epochs
   - D. That the optimization layer has a bug

   *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?

   - A. To replace the realized Sharpe in reports
   - B. To compute the costs
   - 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
   - D. To train the layer

   *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?

   - A. Nothing: the Sharpe already controls risk
   - 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
   - C. The portfolio would always be the minimum variance one
   - D. Diversification would improve

   *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

### Documentation

- [cvxpylayers: Differentiable convex optimization layers (GitHub)](https://github.com/cvxpy/cvxpylayers) · github.com

### Books

- [Elmachtoub, A. N. and Grigas, P. (2017): Smart "Predict, then Optimize" (arXiv:1710.08005)](https://arxiv.org/abs/1710.08005) · arxiv.org
- [Agrawal, A. et al. (2019): Differentiable Convex Optimization Layers (NeurIPS; arXiv:1910.12430)](https://arxiv.org/abs/1910.12430) · arxiv.org
- [Zhang, Z., Zohren, S. and Roberts, S. (2020): Deep Learning for Portfolio Optimization (arXiv:2005.13665)](https://arxiv.org/abs/2005.13665) · arxiv.org
- [Butler, A. and Kwon, R. H. (2021): Integrating prediction in mean-variance portfolio optimization (arXiv:2102.09287)](https://arxiv.org/abs/2102.09287) · arxiv.org

### Data

- [Level 4 data: prices of the multi-asset universe (Parquet)](https://pythonparatrading.com/curso/datos/nivel-4/universo4_precios.parquet) · Download · universo4_precios.parquet
- [Level 4 data: true μ and Σ of the universe (CSV)](https://pythonparatrading.com/curso/datos/nivel-4/universo4_verdad.csv) · Download · universo4_verdad.csv

Previous lesson: [5.7.4 · Graph neural networks for asset relationships](https://pythonparatrading.com/en/python-trading-course/level-5/graph-neural-networks-for-assets/)

Next lesson: [5.8.1 · Time series foundation models: pretrain once, forecast without training](https://pythonparatrading.com/en/python-trading-course/level-5/what-are-time-series-foundation-models/)
