---
title: "5.7.5 Optimización de carteras end-to-end"
description: "Capa de optimización diferenciable en PyTorch, pérdida de Sharpe y walk-forward frente a predecir-y-optimizar, mínima varianza y 1/N."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/optimizacion-de-carteras-end-to-end/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/optimizacion-de-carteras-end-to-end.png"
---

# 5.7.5 · Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe

> La receta clásica predice rendimientos y después optimiza la cartera con esas predicciones. El enfoque end-to-end entrena el modelo de predicción por lo que importa al final, el Sharpe de la cartera, haciendo pasar el gradiente a través del optimizador. Esta lección construye esa capa en PyTorch, la entrena con una pérdida de Sharpe y la evalúa en walk-forward contra las carteras sencillas del nivel 4, con la verdad del universo para separar habilidad de suerte.

- Curso: [Nivel 5: Machine learning y deep learning (Keras/PyTorch)](https://pythonparatrading.com/curso-python-trading/nivel-5/)
- Módulo: 5.7 · Deep learning con PyTorch
- Duración: 75 min
- Requisitos: [5.7.1 · PyTorch: tensores, autograd y el bucle de entrenamiento](https://pythonparatrading.com/curso-python-trading/nivel-5/pytorch-tensores-autograd-y-bucle-de-entrenamiento/), [4.1.4 · Risk parity, mínima varianza, HRP y HERC](https://pythonparatrading.com/curso-python-trading/nivel-4/risk-parity-minima-varianza-hrp-y-herc/), [4.1.2 · Estimación de covarianzas: shrinkage, modelos factoriales y denoising](https://pythonparatrading.com/curso-python-trading/nivel-4/estimacion-de-covarianzas-shrinkage-y-denoising/), [5.4.3 · Walk-forward con reentrenamiento: ventanas y frecuencia](https://pythonparatrading.com/curso-python-trading/nivel-5/walk-forward-con-reentrenamiento/)

## Al terminar esta lección

- Explicar la diferencia entre predecir y luego optimizar (PTO) y aprender de extremo a extremo (E2E) por una pérdida de decisión.
- Escribir una capa de media-varianza diferenciable con solo largos y presupuesto (gradiente proyectado desenrollado) y comprobarla contra un optimizador y contra diferencias finitas.
- Entrenar el modelo con una pérdida de Sharpe y evaluarlo en walk-forward con margen entre entrenamiento y prueba, con costes y con el Sharpe verdadero de los pesos elegidos.
- Reconocer los síntomas del sobreajuste de una pérdida de decisión: Sharpe en muestra que sube, concentración, rotación alta y Sharpe verdadero que baja.

## Predecir y luego optimizar, o aprender la cartera

Las lecciones 4.1.1 a 4.1.4 construyeron carteras en dos pasos: estimar rendimientos esperados y covarianzas, y pasar
esas estimaciones a un optimizador. Si los rendimientos los predice un modelo de machine learning, la receta es la
misma: **predecir y luego optimizar** (PTO). El modelo se entrena por el error de sus predicciones, y el optimizador
no participa en el entrenamiento.

El problema es que el error cuadrático de la predicción no es lo que se quiere minimizar. A una cartera le da igual
acertar el rendimiento de un activo que no va a comprar; le importa mucho el orden entre los activos que se disputan
el peso y lo que pase en las colas. El enfoque **end-to-end** (E2E), o *decision-focused learning*, mete el optimizador
dentro del modelo y entrena por la métrica final: el Sharpe de la cartera resultante. Para eso el gradiente tiene que
atravesar el optimizador, que debe ser una **capa diferenciable**.

```mermaid
flowchart TB
  F[Features] --> M[Modelo mu]
  M --> O[Optimizador]
  S[Covarianza] --> O
  O --> W[Pesos]
  W --> P[Rendimiento]
  P --> L[Pérdida Sharpe]
  L -. gradiente .-> M
```

En PTO, la flecha de vuelta sale de un error de predicción y no pasa por el optimizador. Elmachtoub y Grigas (2017)
formalizaron la idea general (*Smart predict, then optimize*); Zhang, Zohren y Roberts (2020) entrenaron redes que
producen pesos con una capa softmax y una pérdida de Sharpe; Butler y Kwon (2021) integraron la predicción en una capa
de media-varianza. La promesa es razonable. La pregunta de esta lección es la de siempre: si sobrevive a un
walk-forward honesto frente a referencias simples.

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

Datos sintéticos del nivel 4 con la verdad publicada (μ y Σ de cada activo). Sirven para medir qué eligió cada método,
no para afirmar nada sobre carteras reales.

</aside>

## El universo, las features y la covarianza

El universo es el de 38 índices de rentabilidad total del nivel 4 (`universo4_*`), sin `MONET`, el monetario: su
exceso sobre el tipo libre es casi nulo con una volatilidad del 0,2 %, y una pérdida de Sharpe sin más restricciones
puede acabar concentrada en un activo así (pregunta 5 de la autoevaluación). Quedan 37.

Cada fin de mes, para cada activo, tres features tipificadas entre activos (momentum de 12 meses sin el último,
rendimiento del último mes y volatilidad de tres meses), la covarianza del último año y el rendimiento del mes
siguiente en exceso del tipo libre. La covarianza es de Ledoit-Wolf sobre los rendimientos tipificados, es decir,
encogiendo la correlación y no la covarianza: la lección 4.1.2 mostró que el objetivo de identidad escalada estropea
la mínima varianza cuando las volatilidades van del 1 % al 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)          # exceso diario simple
logp = np.log(precios).iloc[1:]               # alineado con r
fin_mes = r.index.to_series().groupby(r.index.to_period("M")).max()
print(f"{len(cols)} activos | {r.index[0].date()} a "
      f"{r.index[-1].date()} | {len(fin_mes)} fines de mes")
```
```output
37 activos | 2006-01-04 a 2025-12-31 | 240 fines de mes
```

```python
def features(i):
    """Features de cada activo al cierre de la sesión i, tipificadas
    entre activos: momentum 12-1, rendimiento de 1 mes, vol. 3 meses."""
    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 de la correlación del último año, mensual."""
    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("decisiones:", meses[0].date(), "a", meses[-1].date())
```
```output
F (227, 37, 3) | S (227, 37, 37) | R (227, 37)
decisiones: 2007-01-31 a 2025-11-28
```

Cada decisión se toma al cierre del último día del mes con datos hasta ese día, y su resultado es el rendimiento del
mes siguiente. La primera decisión necesita un año de historia (enero de 2007); la última, de noviembre de 2025, cubre
diciembre.

## Una capa de optimización diferenciable

La capa resuelve, para unos rendimientos esperados $\hat\mu$ que saldrán del modelo, el problema de media-varianza
de solo largos:

$$
w^\star(\hat\mu) = \arg\min_{w} \ \tfrac{\gamma}{2}\, w'\Sigma w - \hat\mu' w
\quad \text{s.a.} \quad \mathbf{1}'w = 1,\ \ w \ge 0
$$

Si solo hubiera la restricción de presupuesto, las condiciones de Karush-Kuhn-Tucker darían una fórmula cerrada,
diferenciable sin más: la cartera de mínima varianza más una inclinación proporcional a $\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)
$$

Antes de usarla, conviene mirar qué cartera da en este universo:

```python
def mv_presupuesto(mu, Sigma, gamma=5.0):
    """min gamma/2 w'Sw - mu'w con 1'w = 1 (sin 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                                       # enero de 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()}: suma {w_cf.sum():.3f} | bruta "
      f"{w_cf.abs().sum():.2f} | mín. {w_cf.min():.2f} | "
      f"máx. {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: suma 1.000 | bruta 17.42 | mín. -1.67 | máx. 1.85
RF_T20    -1.67
RF_T710   -1.36
RF_T37     1.77
RF_T13     1.85
```

Con una predicción modesta (−0,2 % al mes por cada desviación de volatilidad), la cartera sin la restricción de solo
largos tiene una exposición bruta de 17 veces el capital: largos de 1,85 y 1,77 en los bonos a 1-3 y 3-7 años y cortos
de 1,67 y 1,36 en los de 20 y 7-10 años. Apuesta con mucho apalancamiento por las diferencias entre activos casi
redundantes. Una pérdida de Sharpe explotaría esa libertad. La restricción $w \ge 0$ es la
regularización más barata, y con ella ya no hay fórmula cerrada.

La herramienta estándar para diferenciar un problema convexo con restricciones es **cvxpylayers** (Agrawal et al.,
2019), que deriva las condiciones de optimalidad del problema. La versión 1.2.0 (Apache-2.0) necesita cvxpy, que no
está en el entorno `ml` del curso; así se escribiría:

```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()             # parámetros multiplicando a w
capa_cvx = CvxpyLayer(problema, parameters=[mu, L], variables=[w])
(pesos,) = capa_cvx(mu_tch, torch.linalg.cholesky(S_tch))
```

La alternativa sin dependencias es **desenrollar** un algoritmo iterativo: gradiente proyectado acelerado (FISTA)
sobre el objetivo, con una proyección exacta sobre el conjunto $\{w \ge 0, \mathbf{1}'w = 1\}$ (ordenar, acumular y
cortar). Cada iteración son operaciones de PyTorch, así que autograd deriva a través de las 30 iteraciones como a
través de 30 capas de una red.

Hay un detalle numérico que decide si la capa sirve. Las varianzas del universo van del bono corto (≈ 1,5 % de
volatilidad anual) a la renta variable emergente (≈ 27 %): la covarianza está muy mal condicionada, y el gradiente
proyectado con paso $1/(\gamma\lambda_{\max}(\Sigma))$ avanza a paso de tortuga en las direcciones de los activos poco
volátiles. La solución es **precondicionar** con la diagonal $D = \operatorname{diag}(\Sigma)$: el paso se divide
entre la varianza de cada activo (se trabaja en unidades de volatilidad), el tamaño del paso lo fija el mayor autovalor
de la **correlación** (mucho mejor condicionada) y la proyección se hace en la norma ponderada por $D$, que tiene la
misma solución por ordenación que la euclídea: $w_i = \max(0,\ y_i - \tau/d_i)$, con $\tau$ el que hace que los pesos
sumen 1.

```python
def proyectar(y, d):
    """argmin sum d_i (w_i - y_i)^2 con w >= 0, sum(w) = 1, por filas:
    w_i = max(0, y_i - tau / d_i)."""
    b = y * d                                # puntos de corte
    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 de cada mes
corr = S / torch.sqrt(var[:, :, None] * var[:, None, :])
lam_corr = torch.linalg.eigvalsh(corr)[:, -1:]
print(f"número de condición mediano: covarianza "
      f"{torch.linalg.cond(S).median():.0f}, correlación "
      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
    precondicionado por la diagonal, con la S de cada mes."""
    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 (("mínima varianza", torch.zeros(n)),
                    ("con la predicción", 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}: máx. |w - SLSQP| = "
          f"{np.abs(w_capa - ref).max():.4f} | activos con peso: "
          f"{(w_capa > 1e-3).sum()} (SLSQP: {(ref > 1e-3).sum()})")
```
```output
número de condición mediano: covarianza 23925, correlación 426
mínima varianza: máx. |w - SLSQP| = 0.0022 | activos con peso: 5 (SLSQP: 5)
con la predicción: máx. |w - SLSQP| = 0.0000 | activos con peso: 1 (SLSQP: 1)
```

Con 30 iteraciones precondicionadas, la capa queda a milésimas de peso de la solución de SLSQP en los dos casos (sin
precondicionar, con 30 iteraciones la distancia era de decenas de puntos de peso). Se usarán 30: la capa es una
aproximación fija y diferenciable del optimizador, y es la misma al entrenar y al evaluar.

El segundo control es el gradiente. Autograd deriva el algoritmo desenrollado; las diferencias finitas, el resultado.
Deben parecerse, salvo cuando un activo entra o sale de la cartera (el soporte cambia y la función tiene una esquina):

```python
def sharpe(w, Rm):
    """Sharpe mensual de la cartera w (simples, suma de w * r)."""
    rp = (w * Rm).sum(-1)
    return rp.mean() / rp.std()

ent0 = np.arange(0, 46)                  # 2007-01 a 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("dif. finitas:", np.round(fd, 2))
```
```output
autograd: [ 2.43 -0.6  11.48]
dif. finitas: [ 2.72  0.23 10.61]
```

Las dos componentes grandes (momentum y volatilidad) coinciden en signo y en magnitud aproximada; la del rendimiento
del último mes es pequeña y ni siquiera tiene el mismo signo. No coinciden exactamente porque en $\theta = 0$ hay
activos en el borde del soporte (la mínima varianza tiene cinco activos con peso) y una diferencia finita de $10^{-4}$
cruza esquinas que la derivada local no ve: la pérdida es suave a trozos. Es normal en capas con restricciones de desigualdad, y es una de las
razones por las que el entrenamiento de estas capas es ruidoso.

## El modelo y la pérdida de Sharpe

El modelo es deliberadamente pequeño: $\hat\mu_i = \theta' f_i / 100$, una combinación lineal de las tres features con
tres parámetros (el /100 pone $\hat\mu$ en una escala de rendimientos mensuales y deja $\theta$ de orden 1). Con
$\theta = 0$ la capa devuelve la cartera de mínima varianza; el entrenamiento decide cuánto inclinarse y hacia qué.
La pérdida es el Sharpe mensual con signo menos sobre los meses de entrenamiento:

$$
\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}
$$

Los rendimientos de la cartera son la suma de pesos por rendimientos **simples** de cada activo (con logarítmicos, el
Sharpe de una cartera sale infravalorado: lección 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("Sharpe anualizado en muestra por época:", hist)
print("theta (mom_12_1, ret_1m, vol_3m):", theta_ej.numpy().round(3))
```
```output
Sharpe anualizado en muestra por época: {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]
```

En 150 épocas el Sharpe en muestra sube de 0,96 (la mínima varianza, época 0) a 1,56. El modelo aprendió a pesar en
contra de la volatilidad y a favor del rendimiento del último mes. En 2007-2010 eso describe bien
lo que pasó: los bonos y los activos poco volátiles fueron el refugio de la crisis. Si es una regla o un recuerdo de
46 meses lo dirá el walk-forward.

## Walk-forward: seis carteras

Cada tres años (enero de 2011, 2014, 2017, 2020 y 2023) se reentrena con todos los meses cuyo rendimiento ya terminó
**antes** de la fecha de decisión: el último mes de entrenamiento es el penúltimo, porque el rendimiento del último
termina justo ese día (el margen de la lección 5.4.3). Seis carteras, todas de solo largos y rebalanceadas cada mes:

| Cartera | Qué usa | Parámetros aprendidos |
|---|---|---|
| 1/N | nada | 0 |
| Volatilidad inversa | volatilidades de la covarianza | 0 |
| Mínima varianza | la capa con $\hat\mu = 0$ | 0 |
| PTO | la capa con $\hat\mu$ de una regresión de los rendimientos (menos su media del mes) sobre las features | 3, por mínimos cuadrados |
| E2E media-varianza | la capa con $\hat\mu = \theta' f / 100$ | 3, por la pérdida de Sharpe |
| E2E softmax | $w = \operatorname{softmax}(\theta' f)$, sin covarianza (Zhang et al., 2020) | 3, por la pérdida de Sharpe |

```python
nombres = ["1/N", "vol. inversa", "mín. varianza", "PTO",
           "E2E media-varianza", "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)       # margen de un mes
    # PTO: mínimos cuadrados del rendimiento relativo del mes
    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 con la capa y 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["vol. inversa"][pru] = vi / vi.sum(1, keepdim=True)
        W["mín. varianza"][pru] = capa(cero, pru)
        W["PTO"][pru] = capa(F[pru] @ th_pto, pru)
        W["E2E media-varianza"][pru] = capa(F[pru] @ th_e2e / 100, pru)
        W["E2E softmax"][pru] = torch.softmax(F[pru] @ th_sm, -1)
        sh_ent = {
            "mín. varianza": sharpe(capa(torch.zeros(len(ent), n),
                                         ent), R[ent]),
            "E2E media-varianza": -perdida_en(th_e2e, ent)}
    ventanas.append({
        "entrena hasta": meses[ent[-1]].strftime("%Y-%m"),
        "θ E2E": th_e2e.numpy().round(2).tolist(),
        "Sharpe en muestra E2E": float(sh_ent["E2E media-varianza"])
        * np.sqrt(12),
        "Sharpe en muestra mín. var.": float(sh_ent["mín. varianza"])
        * np.sqrt(12)})
pd.DataFrame(ventanas, index=[2011, 2014, 2017, 2020, 2023]).round(2)
```
```output
     entrena hasta                 θ E2E  Sharpe en muestra E2E  Sharpe en muestra mín. 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
```

En todas las ventanas la E2E tiene en muestra más Sharpe que la mínima varianza, que es la cartera de la que parte
con $\theta = 0$. Su $\theta$ cuenta casi siempre la misma historia: peso en contra de la volatilidad y a favor del
último mes. La pregunta es qué pasa en los tres años siguientes a cada entrenamiento:

```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 media-varianza", "mín. varianza"):
    v[f"Sharpe fuera {m}"] = [
        sharpe_anual(m, np.flatnonzero((meses.year >= a)
                                       & (meses.year < a + 3)))
        for a in v.index]
v.drop(columns=["entrena hasta", "θ E2E"]).round(2).T
```
```output
                                 2011  2014  2017  2020  2023
Sharpe en muestra E2E            1.63  0.89  0.77  0.65  0.20
Sharpe en muestra mín. var.      1.05  0.63  0.61  0.43  0.15
Sharpe fuera E2E media-varianza  0.30  0.66 -0.48 -0.84 -0.38
Sharpe fuera mín. varianza       0.35  0.64 -0.75 -0.67  0.18
```

*Fig. — Sharpe de la cartera E2E de media-varianza en su ventana de entrenamiento y en los tres años siguientes, frente a la mínima varianza en esos mismos años.* Cinco ventanas (prueba desde 2011, 2014, 2017, 2020 y 2023). En muestra, la E2E: 1,63, 0,89, 0,77, 0,65 y 0,20. En los tres años siguientes (destacado): 0,30, 0,66, −0,48, −0,84 y −0,38. La mínima varianza en esos años: 0,35, 0,64, −0,75, −0,67 y 0,18. La ventaja en muestra no se traslada fuera.

En muestra, la E2E gana a la mínima varianza en las cinco ventanas, por 0,05 a 0,58 puntos de Sharpe. En los tres
años siguientes gana en una (2017-2019, −0,48 frente a −0,75), empata en otra (2014-2016) y pierde en tres; en
2023-2025 pierde por más de medio punto. La ventaja en muestra no predice la ventaja fuera, que es la definición
práctica de sobreajuste. En la última ventana, además, el entrenamiento casi no se aleja de $\theta = 0$: con 2022 en
la muestra (bonos y acciones cayendo a la vez), ninguna inclinación mejoró el Sharpe de entrenamiento.

## Resultados fuera de muestra

Para 2011-2025 (179 meses): rendimiento en exceso, volatilidad, Sharpe bruto y neto (10 pb por unidad de rotación,
como en la lección 4.1.5), caída máxima, rotación anual, número efectivo de activos ($1/\sum w_i^2$) y el **Sharpe
verdadero** medio de los pesos elegidos, $w'\mu/\sqrt{w'\Sigma w}$ con la μ y la Σ publicadas del universo
(`universo4_verdad.csv` y `universo4_cov_verdad.csv`, bloque `total`). El último separa lo que eligió cada método de la
suerte que tuvo después, como en la lección 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] = {
        "exceso %": 1200 * rp.mean(),
        "vol. %": 100 * np.sqrt(12) * rp.std(),
        "Sharpe": np.sqrt(12) * rp.mean() / rp.std(),
        "Sharpe neto": np.sqrt(12) * neto.mean() / neto.std(),
        "caída máx. %": 100 * (curva / np.maximum.accumulate(curva)
                               - 1).min(),
        "rotación %": 1200 * rot.mean(),
        "n efectivo": np.mean(1 / (w ** 2).sum(1)),
        "Sharpe verdadero": np.mean([x @ mu_v / np.sqrt(x @ sig_v @ x)
                                     for x in w])}
tabla = pd.DataFrame(filas).T
print(f"{len(prueba)} meses, {meses[prueba[0]].date()} a "
      f"{meses[prueba[-1]].date()}")
print(tabla.round(2).to_string())
```
```output
179 meses, 2011-01-31 a 2025-11-28
                    exceso %  vol. %  Sharpe  Sharpe neto  caída máx. %  rotación %  n efectivo  Sharpe verdadero
1/N                     6.54   13.89    0.47         0.47        -22.83        0.00       37.00              0.39
vol. inversa            3.15    7.68    0.41         0.41        -16.69       26.12       16.53              0.47
mín. varianza          -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 media-varianza     -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
```

La rotación es el primer síntoma: las tres carteras que aprenden algo rotan entre diez y cincuenta veces más que las
simples, y los costes se lo cobran.

*Fig. — Rotación anual frente a Sharpe neto de costes (10 pb por unidad de rotación) de las seis carteras, 2011-2025.* Seis puntos. 1/N: rotación 0 % y Sharpe neto 0,47. Volatilidad inversa: 26 % y 0,41. Mínima varianza: 17 % y −0,03. E2E media-varianza (destacada): 191 % y −0,23. E2E softmax: 532 % y −0,05. PTO: 833 % y 0,36. Las carteras que aprenden algo rotan entre diez y cincuenta veces más que las simples.

La figura de la cabecera pone en una misma escala el Sharpe realizado y el verdadero de cada cartera. Cuatro
lecturas:

- **La E2E de media-varianza es la peor por el Sharpe verdadero (0,30)** y también por el realizado (−0,13; −0,23
  neto). No mejora a la cartera de la que parte con $\theta = 0$, la mínima varianza (0,39 verdadero), y rota diez
  veces más (191 % al año frente a 17 %).
- **La mínima varianza es casi un solo activo**: 1,07 activos efectivos, sobre todo el bono a 1-3 años, como en la
  lección 4.1.4. Su Sharpe realizado (−0,02) infravalora lo que eligió (0,39), porque los bonos del universo rindieron
  en la muestra menos que su esperanza.
- **PTO tiene el mejor Sharpe realizado (0,55) y el segundo peor verdadero (0,31)**. Sus pesos dependen de unas
  predicciones con muy poca señal, saltan de un mes a otro (833 % de rotación al año) y acertaron en el periodo. Es el
  resultado que un backtest premia y que la verdad no respalda.
- **La volatilidad inversa tiene el mejor Sharpe verdadero (0,47)** con 16,5 activos efectivos y un 26 % de rotación:
  la cartera más simple con covarianza gana a todo lo que aprende algo. 1/N, con el realizado más alto después de PTO
  (0,47), tiene un verdadero de 0,39.

## Cuánto ruido tiene la comparación

Con $T$ años de rendimientos mensuales, el error típico de un Sharpe anual es de unos
$\sqrt{(1 + S^2/2)/T}$. Con 15 años:

```python
anyos = len(prueba) / 12
et = np.sqrt((1 + tabla["Sharpe"] ** 2 / 2) / anyos)
pd.DataFrame({"Sharpe": tabla["Sharpe"], "error típico": et,
              "Sharpe verdadero": tabla["Sharpe verdadero"]}).round(2)
```
```output
                    Sharpe  error típico  Sharpe verdadero
1/N                   0.47          0.27              0.39
vol. inversa          0.41          0.27              0.47
mín. varianza        -0.02          0.26              0.39
PTO                   0.55          0.28              0.31
E2E media-varianza   -0.13          0.26              0.30
E2E softmax           0.12          0.26              0.39
```

Un error típico de 0,26 a 0,28 por cartera, mayor que casi todas las distancias entre ellas. El backtest de una sola
historia no puede ordenar estas carteras; el Sharpe verdadero, que en datos reales no existe, sí. Y se han probado seis
métodos (y en un caso real, muchas más variantes de features, γ y épocas): cualquier ganador del Sharpe realizado
tendría que pasar además el descuento del Deflated Sharpe de la lección 5.4.4.

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

La pérdida de Sharpe optimiza exactamente la cifra que se presenta en un backtest. Por eso infla el resultado en
muestra más que un modelo entrenado por error de predicción, y por eso su resultado en muestra no vale nada como
evidencia. Lo único que cuenta es un walk-forward con margen, con costes, contra la cartera a la que se reduce el modelo
con $\theta = 0$.

</aside>

## Cuándo puede funcionar el end-to-end

El resultado no condena la idea; dice qué necesita. La E2E tiene la misma información que PTO y más libertad para
usarla, y la libertad se paga en varianza de estimación:

| Condición | En esta lección | Qué haría falta |
|---|---|---|
| Muchas decisiones independientes | 37 activos, de 46 a 191 meses por ventana | universos de cientos de activos, datos diarios o intradía |
| Una relación estable entre features y rendimientos | las crisis del guion premian a los bonos en unas ventanas y no en otras | features con señal estable (lección 5.4.3) |
| Costes y restricciones dentro de la capa | la rotación se mide después, no se penaliza | penalización de rotación y límites por activo en el problema de la capa |
| Regularización frente a la cartera base | ninguna | penalizar la distancia a la mínima varianza, parar antes, ensembles |
| Validación honesta del procedimiento | walk-forward con margen | también para elegir épocas, γ y features (sin mirar la prueba) |

Los trabajos que encuentran mejoras con E2E (Butler y Kwon, 2021; Uysal et al., 2021 con presupuestos de riesgo)
suelen reducir la libertad del modelo, no aumentarla: capas con más estructura, predicciones que solo inclinan una
cartera robusta y pérdidas que mezclan el error de predicción con el de la decisión. Antes de cualquiera de ellos, la
pregunta de esta lección: ¿mejora a su propia cartera con $\theta = 0$, fuera de muestra y después de costes?

## Ejercicios

1. Entrena la E2E de media-varianza con 2007-2010 durante 450 épocas y mide cada 75 épocas el Sharpe en muestra y en
   2011-2013 (sin reentrenar). ¿Qué pasa con cada uno? ¿Puedes usar la curva de 2011-2013 para elegir el número de
   épocas?

**Solución.**

```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] = {"en muestra": -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
     en muestra  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
```

El Sharpe en muestra sube sin parar con las épocas (de 0,96 a 1,69) y el de 2011-2013 no lo acompaña: se mueve entre
0,30 y 0,37, alrededor del de la mínima varianza de la época 0 (0,35). Las épocas extra solo compran ajuste a 46 meses.
Elegir el número de épocas con la curva de 2011-2013 (aquí, quedarse con la última) sería usar la prueba para ajustar
el modelo: el resultado de 2011-2013 dejaría de ser fuera de muestra. Para parar antes de tiempo hace falta un tramo de validación dentro del entrenamiento (los últimos
meses antes del margen) y, aun así, con 46 meses ese tramo es tan corto que su Sharpe es casi todo ruido.

2. Repite el Sharpe neto de la tabla con costes de 5, 10, 25 y 50 pb por unidad de rotación. ¿A partir de qué coste deja
   PTO de compensar frente a la mínima varianza?

**Solución.**

```python
sens = {}
for c in (5, 10, 25, 50):
    fila = {}
    for m in ("mín. varianza", "PTO", "E2E media-varianza",
              "vol. inversa"):
        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} pb"] = fila
pd.DataFrame(sens).round(2)
```
```output
                    5 pb  10 pb  25 pb  50 pb
mín. varianza      -0.02  -0.03  -0.04  -0.07
PTO                 0.46   0.36   0.08  -0.39
E2E media-varianza -0.18  -0.23  -0.38  -0.61
vol. inversa        0.41   0.41   0.40   0.39
```

PTO sigue por encima de la mínima varianza hasta 25 pb (0,08 frente a −0,04) y cae muy por debajo con 50 pb (−0,39
frente a −0,07): su rotación (más de ocho veces el capital al año) convierte cada punto básico de coste en Sharpe
perdido. La volatilidad inversa casi no se mueve (de 0,41 a 0,39). La comparación sin costes favorece
sistemáticamente a los métodos inestables, y por eso la columna neta de la tabla es la que se mira.

3. La capa de esta lección fija γ = 5 y deja libre la escala de $\theta$. ¿Cambiaría algo entrenar también γ? ¿Y quitar
   la restricción de presupuesto y dejar que la pérdida de Sharpe decida el tamaño?

**Solución.**

Con la escala de $\theta$ libre, γ no añade nada: la cartera depende de $\hat\mu/\gamma$, así que multiplicar $\theta$
por dos equivale a dividir γ por dos. Entrenar los dos daría un parámetro no identificado (el optimizador los movería
juntos sin cambiar la pérdida). Quitar el presupuesto es peor: el Sharpe no cambia al multiplicar todos los pesos por
una constante, así que la pérdida no fija el tamaño de la cartera, y una cartera que solo tiene que ser «la mejor en
Sharpe» puede degenerar (pesos que crecen sin límite, o todo en el activo menos volátil, como `MONET`). Las
restricciones de la capa (presupuesto, solo largos, límites) son las que dan forma al problema; la pérdida solo elige
dentro de ellas.

## Autoevaluación

1. ¿Qué cambia en el enfoque end-to-end respecto a predecir y luego optimizar?

   - A. Que no hace falta covarianza
   - B. Que el optimizador se sustituye por una red neuronal sin restricciones
   - C. Que se usan más features
   - D. Que los parámetros del modelo de predicción se eligen por el resultado de la cartera (la pérdida de Sharpe), propagando el gradiente a través del optimizador, y no por el error de predicción

   *Respuesta D: Que los parámetros del modelo de predicción se eligen por el resultado de la cartera (la pérdida de Sharpe), propagando el gradiente a través del optimizador, y no por el error de predicción.* En PTO el modelo minimiza el error cuadrático de los rendimientos y el optimizador se aplica después. En E2E la misma capa de optimización está dentro del grafo de cálculo y el modelo aprende lo que mejora la decisión. Necesita que el optimizador sea diferenciable.

2. ¿Por qué la lección no usa la solución cerrada de media-varianza con solo la restricción de presupuesto, aunque sea diferenciable y exacta?

   - A. Porque sin `w ≥ 0` la solución toma posiciones cortas y apalancamiento enormes con 37 activos muy correlacionados (aquí, una exposición bruta de 17 veces el capital), y una pérdida de Sharpe explotaría esa libertad
   - B. Porque PyTorch no sabe invertir matrices
   - C. Porque no tiene gradiente
   - D. Porque solo sirve con dos activos

   *Respuesta A: Porque sin `w ≥ 0` la solución toma posiciones cortas y apalancamiento enormes con 37 activos muy correlacionados (aquí, una exposición bruta de 17 veces el capital), y una pérdida de Sharpe explotaría esa libertad.* Las condiciones KKT con solo el presupuesto dan una fórmula cerrada, pero con activos casi redundantes (las versiones cubiertas y sin cubrir de un índice) la cartera óptima apuesta por sus diferencias con mucho apalancamiento. La restricción de solo largos es la regularización más barata, y exige un optimizador iterativo o cvxpylayers.

3. Entrenada con 2007-2010, la cartera E2E tiene un Sharpe en muestra de 1,63 frente a 1,05 de la mínima varianza; en 2011-2013, 0,30 frente a 0,35. ¿Qué indica?

   - A. Que 2011-2013 fue un mal periodo para cualquier cartera
   - B. Sobreajuste de la pérdida de decisión: con 46 meses, la capa aprende a concentrarse en lo que funcionó en la crisis de la ventana de entrenamiento
   - C. Que hay que entrenar más épocas
   - D. Que la capa de optimización tiene un error

   *Respuesta B: Sobreajuste de la pérdida de decisión: con 46 meses, la capa aprende a concentrarse en lo que funcionó en la crisis de la ventana de entrenamiento.* El Sharpe en muestra mide cuánto se ajusta la regla a 46 meses concretos. La E2E optimiza directamente esa cifra, así que la infla más que un modelo entrenado por error de predicción. Fuera de muestra no mejora a la cartera de la que parte; en el conjunto de 2011-2025 queda por debajo de ella.

4. ¿Para qué sirve el «Sharpe verdadero» de los pesos, $w'\mu / \sqrt{w'\Sigma w}$ con la μ y la Σ publicadas del universo?

   - A. Para sustituir al Sharpe realizado en los informes
   - B. Para calcular los costes
   - C. Para separar lo que eligió cada método de la suerte que tuvo después: con 15 años, el error típico de un Sharpe realizado es de unas 0,26, más que muchas de las diferencias entre métodos
   - D. Para entrenar la capa

   *Respuesta C: Para separar lo que eligió cada método de la suerte que tuvo después: con 15 años, el error típico de un Sharpe realizado es de unas 0,26, más que muchas de las diferencias entre métodos.* En datos reales no existe. En el universo sintético sí, y permite ver que un Sharpe realizado alto puede venir de pesos con un Sharpe esperado bajo (y al revés).

5. La pérdida de Sharpe es invariante a la escala de la cartera. ¿Qué pasaría si se quitara la restricción de presupuesto y se dejara un activo monetario casi sin volatilidad?

   - A. Nada: el Sharpe ya controla el riesgo
   - B. El modelo podría concentrarse en el activo monetario, que con una volatilidad minúscula tiene un Sharpe en muestra arbitrario, o escalar los pesos sin límite; la pérdida necesita restricciones que fijen la escala y un universo sin activos degenerados
   - C. La cartera sería siempre la de mínima varianza
   - D. Mejoraría la diversificación

   *Respuesta B: El modelo podría concentrarse en el activo monetario, que con una volatilidad minúscula tiene un Sharpe en muestra arbitrario, o escalar los pesos sin límite; la pérdida necesita restricciones que fijen la escala y un universo sin activos degenerados.* Por eso la lección retira `MONET` del universo y la capa impone `1′w = 1` y `w ≥ 0`. Las restricciones de la capa son parte del modelo, no un detalle de implementación.

## Ideas clave

- PTO entrena por error de predicción y optimiza después; E2E entrena por la métrica de la cartera con el optimizador dentro del grafo de cálculo. Necesita una capa de optimización diferenciable.
- La media-varianza con solo el presupuesto tiene solución cerrada (KKT), pero con 37 activos correlacionados da apalancamiento extremo. Con `w ≥ 0`, la capa se resuelve por gradiente proyectado desenrollado (aquí) o con cvxpylayers (no instalado en el entorno `ml`).
- Con la covarianza mal condicionada, el gradiente proyectado necesita precondicionarse por la diagonal: con 30 iteraciones queda a milésimas de peso de SLSQP. El gradiente por autograd se parece al de diferencias finitas salvo en las esquinas (cambios de soporte). Las dos comprobaciones van antes de entrenar.
- La pérdida de Sharpe sobreajusta: en muestra, la E2E supera a la mínima varianza en las cinco ventanas (1,63 frente a 1,05 en la primera); fuera de muestra (2011-2025), Sharpe de −0,13 y −0,23 neto, con un Sharpe verdadero de 0,30, el peor de las seis carteras.
- Con 15 años, el error típico de un Sharpe realizado es de unas 0,27: PTO tiene el mejor realizado (0,55) y el segundo peor verdadero (0,31). El Sharpe verdadero ordena: volatilidad inversa 0,47, después 1/N, mínima varianza y E2E softmax (0,39), PTO y E2E media-varianza.
- Lo que pide el end-to-end para funcionar: muchas decisiones independientes, una relación estable, costes y restricciones dentro de la capa y regularización. Con 37 activos y 18 años de meses, no los hay.

## Recursos

### Documentación

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

### Libros

- [Elmachtoub, A. N. y 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. y Roberts, S. (2020): Deep Learning for Portfolio Optimization (arXiv:2005.13665)](https://arxiv.org/abs/2005.13665) · arxiv.org
- [Butler, A. y Kwon, R. H. (2021): Integrating prediction in mean-variance portfolio optimization (arXiv:2102.09287)](https://arxiv.org/abs/2102.09287) · arxiv.org

### Datos

- [Datos del nivel 4: precios del universo multiactivo (Parquet)](https://pythonparatrading.com/curso/datos/nivel-4/universo4_precios.parquet) · Descarga · universo4_precios.parquet
- [Datos del nivel 4: μ y Σ verdaderas del universo (CSV)](https://pythonparatrading.com/curso/datos/nivel-4/universo4_verdad.csv) · Descarga · universo4_verdad.csv

Lección anterior: [5.7.4 · Redes de grafos para relaciones entre activos](https://pythonparatrading.com/curso-python-trading/nivel-5/redes-de-grafos-para-activos/)

Lección siguiente: [5.8.1 · Modelos fundacionales de series: preentrenar una vez, prever sin entrenar](https://pythonparatrading.com/curso-python-trading/nivel-5/que-son-los-modelos-fundacionales-de-series/)
