Lección 5.4.4 · 70 min

Backtest de señales de ML con costes y Deflated Sharpe

Un IC positivo y estable no es dinero. Entre la predicción y el resultado están la regla que convierte puntuaciones en pesos, la rotación que esa regla genera y lo que cuesta cada operación. Esta lección hace ese camino con el ridge del walk-forward de 5.4.3, registra cada variante que prueba y descuenta al final la selección con el Deflated Sharpe de la lección 2.5.4.

Fig. — Rendimiento acumulado de la cartera larga-corta por deciles del ridge del walk-forward en el panel del nivel 5, bruto y neto de 10 pb por lado (2015-2025)
Lecciones del nivel 5
  1. 5.1 · Fundamentos de ML aplicados a finanzas

    1. 5.1.1 Por qué el machine learning financiero es distinto
    2. 5.1.2 Aprendizaje supervisado, no supervisado y por refuerzo; pipelines de scikit-learn
    3. 5.1.3 Métricas económicas para modelos de trading: más allá del acierto
  2. 5.2 · Ingeniería de características

    1. 5.2.1 Features de precio, volumen y microestructura
    2. 5.2.2 Fundamentales point-in-time, macro y datos alternativos
    3. 5.2.3 Diferenciación fraccional para features
    4. 5.2.4 Normalización sin leakage y rank transversal
    5. 5.2.5 Importancia de features: MDI, MDA, SFI y SHAP
  3. 5.3 · Etiquetado y muestreo

    1. 5.3.1 Etiquetado de horizonte fijo y sus problemas
    2. 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
    3. 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
    4. 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
    5. 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
  4. 5.4 · Validación para series financieras

    1. 5.4.1 Por qué falla el k-fold aleatorio en datos financieros
    2. 5.4.2 Purged k-fold con embargo y CPCV para modelos de ML
    3. 5.4.3 Walk-forward con reentrenamiento: ventanas y frecuencia
    4. 5.4.4 Backtest de señales de ML con costes y Deflated Sharpe
  5. 5.5 · Modelos clásicos

    1. 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
    2. 5.5.2 Árboles de decisión, Random Forest y bagging
    3. 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
    4. 5.5.4 Optuna y el riesgo de sobreajustar la validación
    5. 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
    6. 5.5.6 Learning to rank para la selección de acciones
  6. 5.6 · Deep learning con Keras 3

    1. 5.6.1 Keras 3: un modelo, varios motores de cálculo
    2. 5.6.2 Un MLP sobre un panel de acciones: regularizar y comparar
    3. 5.6.3 CNN 1D, LSTM y GRU: ventanas, escalado y fugas
    4. 5.6.4 Callbacks, una pérdida de Sharpe y el modelo en producción
  7. 5.7 · Deep learning con PyTorch

    1. 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
    2. 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
    3. 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
    4. 5.7.4 Redes de grafos para relaciones entre activos
    5. 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
  8. 5.8 · Modelos fundacionales de series temporales

    1. 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
    2. 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
    3. 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
    4. 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
    5. 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
  9. 5.9 · Aprendizaje por refuerzo

    1. 5.9.1 MDP, Q-learning, PPO y entornos Gymnasium
    2. 5.9.2 RL para ejecución, market making y cobertura dinámica
    3. 5.9.3 Por qué el RL sobreajusta en trading direccional
  10. 5.10 · Interpretabilidad, robustez y riesgo de modelo

    1. 5.10.1 SHAP, dependencia parcial y estabilidad de las importancias
    2. 5.10.2 Deriva de datos y de concepto: detectarla y qué hacer
    3. 5.10.3 Ensembles, combinación de señales y datos sintéticos

Del IC al dinero

El walk-forward de la lección 5.4.3 terminó con un ridge de IC 0,0185 (t = 4,9) en 574 semanas: una señal real, estable y estadísticamente clara. La pregunta de esta lección es la que importa a quien tiene que operarla: ¿cuánto gana una cartera construida con esa señal, después de pagar por cambiarla cada semana? El camino tiene cuatro pasos, y un quinto que se suele olvidar:

PuntuaciónRegla de carteraPesos por semanaRotación y costesRendimiento netoRegistroDeflated Sharpe
Fuente Mermaid
flowchart TB
  S[Puntuación] --> C[Regla de cartera]
  C --> W[Pesos por semana]
  W --> T[Rotación y costes]
  T --> N[Rendimiento neto]
  N --> R[Registro]
  R --> D[Deflated Sharpe]

El quinto es el registro: cada variante de modelo y de cartera que se prueba es un intento, y el Sharpe de la mejor solo significa algo descontando cuántos intentos hubo (lección 2.5.4).

Las puntuaciones: el walk-forward de 5.4.3

Cada lección se ejecuta por separado, así que esta rehace en una celda lo necesario de la 5.4.3: el panel en arrays, el ridge desde estadísticos suficientes y el walk-forward con la regla del tiempo. Añade dos modelos de referencia: el oráculo y el ridge con revision_analistas, la feature con fuga.

IN [01]python
import numpy as np
import pandas as pd
from scipy.stats import norm, rankdata

feat = pd.read_parquet("panel5_features.parquet")
feat = feat.sort_values(["fecha", "ticker"])
etiq = pd.read_parquet("panel5_etiquetas.parquet")
etiq = etiq.sort_values(["fecha", "ticker"])
verdad = pd.read_parquet("panel5_verdad.parquet")
verdad = verdad.sort_values(["fecha", "ticker"])
TODAS = [c for c in feat.columns if c not in
         ("fecha", "ticker", "sector")]
fechas = pd.DatetimeIndex(feat["fecha"].unique())
nW, nA = len(fechas), feat["ticker"].nunique()

F = feat[TODAS].to_numpy(dtype=float).reshape(nW, nA, -1)
with np.errstate(invalid="ignore", divide="ignore"):
    n = np.isfinite(F).sum(axis=1, keepdims=True)
    G = np.nan_to_num(norm.ppf(
        (rankdata(F, axis=1, nan_policy="omit") - 0.5) / n))
R = etiq["ret_sig_semana"].to_numpy().reshape(nW, nA)
fin = etiq["fin_sig_semana"].to_numpy().reshape(nW, nA)[:, 0]
con_feat = np.isfinite(F[:, :, 0]).all(axis=1)
valida = con_feat & np.isfinite(R).all(axis=1)
Yc = np.zeros_like(R)
Yc[valida] = R[valida] - R[valida].mean(axis=1, keepdims=True)
anyo = fechas.year.to_numpy()
semanas = np.flatnonzero(valida & (anyo >= 2015))  # backtest

def estadisticos(cols):
    X1 = np.concatenate([np.ones((nW, nA, 1)), G[:, :, cols]], 2)
    pv = valida.astype(float)
    return (np.einsum("wni,wnj->wij", X1, X1) * pv[:, None, None],
            np.einsum("wni,wn->wi", X1, Yc) * pv[:, None])

def walk_forward(cols, ventana=None, cada=None, anual=False,
                 semivida=None, alfa=10.0):
    S_xx, S_xy = estadisticos(cols)
    k = len(cols)
    pred, beta = np.full((nW, nA), np.nan), None
    for i, w in enumerate(semanas):
        if (beta is None or (cada and i % cada == 0) or
                (anual and anyo[w] != anyo[semanas[i - 1]])):
            u = int(np.searchsorted(fin, fechas[w])) - 1
            p = valida.astype(float)
            p[u + 1:] = 0.0
            if ventana:
                p[:max(0, u + 1 - ventana)] = 0.0
            if semivida:
                p *= 0.5 ** ((u - np.arange(nW)) / semivida)
            A, b = np.tensordot(p, S_xx, 1), np.tensordot(p, S_xy, 1)
            mx, my = A[0, 1:] / A[0, 0], b[0] / A[0, 0]
            C = A[1:, 1:] - A[0, 0] * np.outer(mx, mx)
            c = b[1:] - A[0, 0] * mx * my
            beta = np.linalg.solve(C + alfa * np.eye(k), c)
        pred[w] = G[w][:, cols] @ beta
    return pred

sin_fuga = [i for i, c in enumerate(TODAS)
            if c != "revision_analistas"]
modelos = {
    "exp. anual": walk_forward(sin_fuga, anual=True),
    "exp. semanal": walk_forward(sin_fuga, cada=1),
    "móvil 3 años": walk_forward(sin_fuga, ventana=156, cada=1),
    "semivida 2 años": walk_forward(sin_fuga, semivida=104, cada=1),
}
fuga = walk_forward(list(range(len(TODAS))), anual=True)
oraculo = verdad["alfa_verdadero"].to_numpy().reshape(nW, nA)
print(f"{len(semanas)} semanas de backtest: "
      f"{fechas[semanas[0]].date()} a {fechas[semanas[-1]].date()}")
OUT [01]Panel, ridge en walk-forward y modelos de referencia
574 semanas de backtest: 2015-01-02 a 2025-12-26

De puntuaciones a pesos, con costes

La regla de cartera es la del README: cada semana, larga en el decil de puntuación más alta y corta en el más baja, equiponderada, un dólar por pata (dos de nominal bruto). Entre una semana y la siguiente, los pesos derivan con los rendimientos; la rotación es la suma de los cambios absolutos de peso necesarios para volver a la cartera objetivo, y el coste, 10 pb por cada unidad de rotación:

rotacioˊnt=∑i∣wi,t−w~i,t−1∣,rtneto=∑iwi,t ri,t+1−c⋅rotacioˊnt\text{rotación}_t = \sum_i \left| w_{i,t} - \tilde w_{i,t-1} \right|, \qquad r^{\text{neto}}_t = \sum_i w_{i,t}\, r_{i,t+1} - c \cdot \text{rotación}_t

con w~i,t−1=wi,t−1(1+ri,t)\tilde w_{i,t-1} = w_{i,t-1}(1 + r_{i,t}) el peso de la semana anterior después de moverse con el mercado y cc = 0,001. La función admite tres variantes que reducen la rotación y se usan más abajo:

  • cada: rebalancear solo cada n semanas (entre medias, los pesos derivan).
  • suavizado: operar con una media exponencial del rango de la puntuación, st=λ rangot+(1−λ) st−1s_t = \lambda\,\text{rango}_t + (1 - \lambda)\,s_{t-1}, en lugar de con la puntuación de esa semana. Es la idea de Gârleanu y Pedersen (2013): moverse solo una parte del camino hacia la cartera objetivo.
  • banda: entrar en el 10 % de los extremos y no salir mientras se siga en el 30 % (una banda entre comprar y mantener, como las que estudian Novy-Marx y Velikov, 2016).
IN [02]python
def cartera(score, q=0.1, cada=1, suavizado=None, banda=None,
            coste=0.001):
    """Rendimientos semanales brutos y netos y rotación media."""
    w_ant = np.zeros(nA)
    s_prev = None
    bruto, neto, rot = [], [], []
    for i, w in enumerate(semanas):
        rk = (rankdata(score[w]) - 0.5) / nA
        if suavizado:
            s_prev = rk if s_prev is None else (
                suavizado * rk + (1 - suavizado) * s_prev)
            rk = (rankdata(s_prev) - 0.5) / nA
        if i % cada == 0:
            largo, corto = rk >= 1 - q, rk <= q
            if banda:
                largo |= (w_ant > 0) & (rk >= 1 - banda)
                corto |= (w_ant < 0) & (rk <= banda)
            peso = np.zeros(nA)
            peso[largo] = 1 / largo.sum()
            peso[corto] = -1 / corto.sum()
        else:
            peso = w_ant
        r = float(peso @ R[w])
        t = np.abs(peso - w_ant).sum()
        bruto.append(r)
        neto.append(r - coste * t)
        rot.append(t)
        w_ant = peso * (1 + R[w])
    return np.array(bruto), np.array(neto), float(np.mean(rot[1:]))

def sharpe(x):
    return x.mean() / x.std(ddof=1) * np.sqrt(52)

filas = {}
for nombre, sc in (("oráculo", oraculo),
                   ("ridge (exp. anual)", modelos["exp. anual"]),
                   ("ridge + fuga", fuga)):
    b, nt, rot = cartera(sc)
    filas[nombre] = {"rend. bruto": f"{b.mean() * 52:.1%}",
                     "vol.": f"{b.std(ddof=1) * np.sqrt(52):.1%}",
                     "Sharpe bruto": round(sharpe(b), 2),
                     "rotación": f"{rot:.0%}",
                     "rend. neto": f"{nt.mean() * 52:.1%}",
                     "Sharpe neto": round(sharpe(nt), 2)}
pd.DataFrame(filas).T
OUT [02]Cartera larga-corta por deciles con rotación y costes
                   rend. bruto  vol. Sharpe bruto rotación rend. neto Sharpe neto
oráculo                  17.1%  9.6%         1.78     259%       3.6%        0.38
ridge (exp. anual)       12.1%  9.7%         1.25     265%      -1.7%       -0.18
ridge + fuga            136.5%  6.9%        19.89     287%     121.6%       17.74

Las tres filas son las del README. El oráculo, que conoce el alfa verdadero, tiene un Sharpe bruto de 1,78 y neto de 0,38: incluso la mejor información posible apenas sobrevive a una rotación del 259 % semanal. El ridge honesto pasa de 1,25 bruto a −0,18 neto. El de la fuga, de 19,9 a 17,7: un número imposible, que en un backtest real tendría que disparar la alarma antes que cualquier contraste.

La figura de la cabecera muestra la diferencia en el tiempo para el ridge: la curva bruta sube con regularidad; la neta, con la misma forma, termina por debajo de donde empezó.

La cuenta del coste

La aritmética es sencilla y conviene hacerla antes de ningún backtest. Con una rotación semanal τ\tau y un coste cc por unidad de rotación, el coste anual es 52 τ c52\,\tau\,c. La señal soporta el coste mientras su rendimiento bruto anual lo supere:

IN [03]python
b, nt, rot = cartera(modelos["exp. anual"])
coste_anual = 52 * rot * 0.001
equilibrio = b.mean() * 52 / (52 * rot)
print(f"rendimiento bruto anual {b.mean() * 52:.1%}; "
      f"coste anual {coste_anual:.1%}")
print(f"coste de equilibrio: {equilibrio * 1e4:.1f} pb por lado")
OUT [03]Coste anual y coste de equilibrio
rendimiento bruto anual 12.1%; coste anual 13.8%
coste de equilibrio: 8.8 pb por lado

El ridge gana un 12,1 % bruto al año y paga un 13,8 % en costes. Soportaría unos 8,8 pb por lado: con los 10 del README, pierde. Hay dos palancas: más rendimiento bruto por unidad de rotación (mejor señal) o menos rotación para el mismo rendimiento (mejor regla de cartera). La segunda es la que se puede tocar sin volver a modelizar.

Un registro de experimentos

Antes de probar variantes, el registro. Cada experimento guarda su configuración completa, sus métricas y su serie de rendimientos netos: sin la serie no se puede calcular la dispersión de los Sharpes ni la PBO al final (lección 2.2.4). El registro guarda también las variantes que no funcionan, que son las que más información dan sobre la suerte.

La malla es pequeña y razonable, la que cualquiera probaría: los cuatro modelos del walk-forward de 5.4.3 (expansivo anual y semanal, móvil de tres años, pesos con semivida de dos años) por cinco reglas de cartera (deciles, quintiles, deciles cada 4 semanas, rangos suavizados con λ = 0,25 y banda del 30 %). Veinte experimentos.

IN [04]python
reglas = {"deciles": {}, "quintiles": {"q": 0.2},
          "cada 4 semanas": {"cada": 4},
          "suavizado 0,25": {"suavizado": 0.25},
          "banda 30 %": {"banda": 0.3}}
registro, series = [], {}
for m, sc in modelos.items():
    for r, kw in reglas.items():
        b, nt, rot = cartera(sc, **kw)
        clave = f"{m} | {r}"
        registro.append({"experimento": clave, "modelo": m,
                         "regla": r, "parámetros": kw,
                         "Sharpe bruto": sharpe(b),
                         "Sharpe neto": sharpe(nt), "rotación": rot})
        series[clave] = nt
registro = pd.DataFrame(registro).set_index("experimento")
netos = pd.DataFrame(series, index=fechas[semanas])
print(f"experimentos registrados: {len(registro)}")
(registro[["Sharpe bruto", "Sharpe neto", "rotación"]]
 .sort_values("Sharpe neto", ascending=False).head(8).round(2))
OUT [04]Veinte experimentos registrados
experimentos registrados: 20
                                  Sharpe bruto  Sharpe neto  rotación
experimento
exp. anual | suavizado 0,25               0.88         0.37      0.86
exp. semanal | suavizado 0,25             0.79         0.25      0.88
exp. anual | cada 4 semanas               0.59         0.18      0.73
exp. anual | banda 30 %                   1.09         0.12      1.44
exp. semanal | cada 4 semanas             0.46         0.05      0.73
semivida 2 años | suavizado 0,25          0.56         0.05      0.86
exp. semanal | banda 30 %                 1.00         0.03      1.49
exp. anual | deciles                      1.25        -0.18      2.65
Fig. — Los 20 experimentos registrados (4 modelos de walk-forward por 5 reglas de cartera) en el panel del nivel 5: rotación semanal frente a Sharpe neto de 10 pb por lado

La figura pone los veinte experimentos en el plano rotación-Sharpe neto. Se ve la regla: las variantes de mucha rotación (deciles y quintiles semanales, a la derecha) pierden dinero con cualquier modelo; las de poca rotación (rebalanceo cada 4 semanas, suavizado, banda) quedan cerca de cero o algo por encima con los modelos expansivos, y por debajo con la ventana móvil, que la lección 5.4.3 ya descartó por su IC. La mejor es el ridge expansivo anual con rangos suavizados: Sharpe neto de 0,37, con una rotación del 86 %. Casi lo mismo que el oráculo con deciles semanales.

El Deflated Sharpe del mejor

¿Es 0,37 un resultado? Tres preguntas, de menos a más exigentes. Las funciones son las de la lección 2.5.4, con los Sharpes en la frecuencia de los datos (semanal):

PSR^(SR∗)=Φ((SR^−SR∗)T−11−γ^3 SR^+γ^4−14 SR^2),SR0=V[SR^n][(1−γ) Φ−1 ⁣(1−1N)+γ Φ−1 ⁣(1−1Ne)]\widehat{PSR}(SR^*) = \Phi\left(\frac{(\widehat{SR} - SR^*)\sqrt{T-1}}{\sqrt{1 - \widehat{\gamma}_3\,\widehat{SR} + \frac{\widehat{\gamma}_4 - 1}{4}\,\widehat{SR}^2}}\right), \qquad SR_0 = \sqrt{V[\widehat{SR}_n]}\left[(1-\gamma)\,\Phi^{-1}\!\left(1-\tfrac{1}{N}\right) + \gamma\,\Phi^{-1}\!\left(1-\tfrac{1}{N e}\right)\right]
IN [05]python
def psr(sr, umbral, t, asim, curt):
    den = np.sqrt(1 - asim * sr + (curt - 1) / 4 * sr ** 2)
    return float(norm.cdf((sr - umbral) * np.sqrt(t - 1) / den))

def sr_max_esperado(n, var_sr):
    g = np.euler_gamma
    return np.sqrt(var_sr) * ((1 - g) * norm.ppf(1 - 1 / n)
                              + g * norm.ppf(1 - 1 / (n * np.e)))

sr_sem = netos.mean() / netos.std()       # Sharpes semanales
mejor = sr_sem.idxmax()
x = netos[mejor]
T, asim, curt = len(x), x.skew(), x.kurt() + 3
sr = sr_sem[mejor]
et_anual = np.sqrt((1 + sr ** 2 / 2) / T) * np.sqrt(52)
print(f"mejor: {mejor}")
print(f"Sharpe neto {sr * np.sqrt(52):.2f} ± {et_anual:.2f} "
      f"(error típico, {T} semanas)")
print(f"PSR(0) = {psr(sr, 0, T, asim, curt):.3f}")

autoval = np.linalg.eigvalsh(netos.corr().to_numpy())
n_ef = autoval.sum() ** 2 / (autoval ** 2).sum()
for etiqueta, n_int in (("intentos efectivos", n_ef),
                        ("20 registrados", 20),
                        ("con los 13 de 5.4.3 x 5 reglas", 65)):
    sr0 = sr_max_esperado(n_int, sr_sem.var())
    print(f"N = {n_int:5.1f} ({etiqueta}): SR_0 = "
          f"{sr0 * np.sqrt(52):.2f} anual, "
          f"DSR = {psr(sr, sr0, T, asim, curt):.3f}")
OUT [05]Error típico, PSR y Deflated Sharpe del mejor experimento
mejor: exp. anual | suavizado 0,25
Sharpe neto 0.37 ± 0.30 (error típico, 574 semanas)
PSR(0) = 0.890
N =   2.0 (intentos efectivos): SR_0 = 0.20 anual, DSR = 0.713
N =  20.0 (20 registrados): SR_0 = 0.71 anual, DSR = 0.128
N =  65.0 (con los 13 de 5.4.3 x 5 reglas): SR_0 = 0.89 anual, DSR = 0.042

Primera pregunta: ¿se distingue de cero, como si fuera el único intento? El error típico de un Sharpe anual con once años de datos semanales es de ±0,30, casi tanto como el propio Sharpe, y el PSR(0) es 0,89: ni siquiera sin corregir llega al 95 %.

Segunda: ¿supera al mejor esperado entre los intentos? Los 20 experimentos están muy correlacionados (comparten modelos y reglas), y la razón de participación de los autovalores de su matriz de correlación da solo 2 intentos efectivos: con ese N tan favorable, el umbral es un Sharpe neto de 0,20 y el DSR, 0,71. Con los 20 declarados, el umbral sube a 0,71 y el DSR baja a 0,13. Y la cuenta honesta incluye los intentos de la lección anterior: el walk-forward de 5.4.3 probó trece esquemas antes de elegir los cuatro de aquí, y cada uno podría haberse combinado con cinco reglas; con 65, el DSR baja a 0,04. Ninguna forma de contar llega al 0,95.

Tercera: ¿qué se presenta? Lo que los números permiten: el ridge honesto da un Sharpe neto compatible con cero, entre −0,4 y 0,4 según la regla de cartera con los modelos expansivos, con un error típico de ±0,3. Es la respuesta del README («Sharpe neto ≈ 0») y es la conclusión correcta, aunque el IC bruto sea claramente positivo.

La figura resume la segunda pregunta: el DSR del mejor experimento según cuántos intentos se declaren. Ninguna forma razonable de contar lo acerca a la línea de 0,95.

Fig. — Deflated Sharpe del mejor de los 20 experimentos (Sharpe neto 0,37 en 574 semanas) según el número de intentos que se declaren

El DSR no ve las fugas

El ridge con revision_analistas no necesita ninguna corrección para parecer bueno, y ninguna corrección lo descubre:

IN [06]python
_, nt_fuga, rot_fuga = cartera(fuga)
sr_f = nt_fuga.mean() / nt_fuga.std(ddof=1)
var_con = pd.concat([sr_sem, pd.Series({"fuga": sr_f})]).var()
sr0 = sr_max_esperado(21, var_con)
xf = pd.Series(nt_fuga)
dsr_f = psr(sr_f, sr0, len(xf), xf.skew(), xf.kurt() + 3)
print(f"Sharpe neto {sr_f * np.sqrt(52):.1f}; SR_0 con N = 21: "
      f"{sr0 * np.sqrt(52):.2f}; DSR = {dsr_f:.3f}")
OUT [06]El Deflated Sharpe de un modelo con fuga
Sharpe neto 17.7; SR_0 con N = 21: 7.58; DSR = 1.000

Un Sharpe neto de 17,7 deja atrás cualquier umbral, incluso el de 7,6 que sale al meter la propia fuga en la dispersión de los intentos: el DSR es 1,000. El DSR descuenta la selección (haber elegido el mejor de N intentos sin ventaja), no la información del futuro: con una fuga, la ventaja es real dentro del backtest, porque el backtest la ve. Lo único que la delata es el orden de magnitud (un Sharpe neto semanal de 17 en acciones no existe) y, sobre todo, la auditoría de cuándo se conoce cada dato (lección 5.2.2). La validación (5.4.1-5.4.3) y la corrección por selección (esta lección) se aplican después de esa auditoría, no en su lugar.

Qué presentar

PreguntaRespuesta en este panel
¿Tiene señal el modelo?Sí: IC 0,0185, t = 4,9 (lección 5.4.3)
¿Gana dinero con la regla de partida?No: Sharpe neto −0,18 con deciles semanales
¿Y con la mejor regla de cartera?0,37 ± 0,30, PSR(0) 0,89
¿Sobrevive a la selección?No: DSR 0,13 con 20 intentos, 0,71 con los 2 efectivos
¿Cuál es el techo?El oráculo: 0,38 neto con deciles semanales
ConclusiónSeñal real, beneficio neto compatible con cero; trabajo pendiente en costes y ejecución

La última fila no es un fracaso: es información. Dice que el siguiente esfuerzo no está en otro modelo (el ridge ya recoge una parte apreciable de lo que recoge el oráculo) sino en la ejecución: costes más bajos (lección 4.3.1), carteras optimizadas con penalización de la rotación (lección 4.1.5) o una señal combinada con otras menos correlacionadas (lección 5.10.3).

Ejercicios

  1. Aplica la mejor regla de cartera (rangos suavizados con λ = 0,25) al oráculo. ¿Cuál es el mejor Sharpe neto que permite este panel con esa regla, y cuánto le falta al ridge?
Solución
IN [07]python
for nombre, sc in (("oráculo", oraculo),
                   ("ridge exp. anual", modelos["exp. anual"])):
    b, nt, rot = cartera(sc, suavizado=0.25)
    print(f"{nombre:<18} bruto {sharpe(b):.2f}  neto {sharpe(nt):.2f}"
          f"  rotación {rot:.0%}")
OUT [07]El oráculo con la regla suavizada
oráculo            bruto 1.14  neto 0.64  rotación 91%
ridge exp. anual   bruto 0.88  neto 0.37  rotación 86%

Con la regla suavizada, el oráculo pasa de 0,38 a 0,64 de Sharpe neto y el ridge queda en 0,37: la regla no solo ayuda al ridge, ayuda a cualquier señal lenta. La distancia entre los dos netos es lo que queda por ganar con un modelo mejor; la distancia entre el neto y el bruto del oráculo, lo que queda por ganar con mejor ejecución. Comparar siempre con el techo dice en qué trabajar.

  1. Calcula el coste por lado de equilibrio de la mejor variante (ridge expansivo anual, suavizado 0,25). ¿Con qué costes reales sería operable?
Solución
IN [08]python
b, nt, rot = cartera(modelos["exp. anual"], suavizado=0.25)
print(f"rotación {rot:.0%} semanal; bruto anual {b.mean() * 52:.1%};"
      f" equilibrio {b.mean() / rot * 1e4:.1f} pb por lado")
OUT [08]Coste de equilibrio de la mejor variante
rotación 86% semanal; bruto anual 7.7%; equilibrio 17.2 pb por lado

Con un 86 % de rotación semanal y un 7,7 % bruto al año, el ridge suavizado soporta 17,2 pb por lado, el doble que la versión de deciles semanales (8,8). Eso lo pone al alcance de carteras en acciones líquidas, donde el coste total (comisión, horquilla e impacto) puede quedar por debajo; pero el margen sobre los 10 pb es de unos 7 pb, el impacto crece con el tamaño, y el Sharpe resultante no se distingue de cero. La conclusión práctica es la misma: antes de operarlo hay que medir los costes de verdad con la cartera de verdad.

  1. Escribe qué campos tendría el registro de un experimento en un proyecto real, más allá de los de la celda.
Solución

Además de la configuración y de la serie de rendimientos netos: la versión de los datos (y su huella, por ejemplo un hash del fichero), la versión del código (commit), las semillas, el periodo de entrenamiento y de prueba, el esquema de validación con sus parámetros (purga, embargo, ventana, frecuencia), el modelo de costes, la fecha y la persona que lo lanzó, y una nota de por qué se probó. También los experimentos que fallaron o se abortaron. Con eso, cualquier número del informe final se puede reproducir y el número de intentos se puede contar sin depender de la memoria (lección 2.2.4). Herramientas como MLflow lo automatizan, pero un fichero de texto con una fila por experimento basta si se mantiene con disciplina.

Respuesta plegada en cada pregunta

Autoevaluación

  1. El ridge del walk-forward tiene un Sharpe bruto de 1,25 con una rotación semanal del 265 % y costes de 10 pb por lado. ¿Por qué el neto es −0,18?

    • APorque el modelo está sobreajustado
    • BPorque la rotación cuesta 2,65 × 52 × 10 pb ≈ 13,8 % al año y la cartera gana un 12,1 % bruto: el coste se come toda la ventaja y algo más
    • CPorque los costes de 10 pb son irreales
    • DPorque el Sharpe neto se calcula con rendimientos logarítmicos
    Ver respuesta

    Respuesta B. Porque la rotación cuesta 2,65 × 52 × 10 pb ≈ 13,8 % al año y la cartera gana un 12,1 % bruto: el coste se come toda la ventaja y algo más

    El rendimiento bruto anual es del 12,1 %; el coste anual, la rotación por el coste por unidad, un 13,8 %. El punto de equilibrio está en unos 8,8 pb por lado: con 10, se pierde.

  2. La mejor de las 20 variantes del registro (rebalanceo con puntuaciones suavizadas) tiene un Sharpe neto de 0,37. ¿Qué dice su Deflated Sharpe con N = 20?

    • AQue, con la dispersión de los 20 intentos, el mejor esperado por azar ronda un Sharpe neto de 0,71: un 0,37 queda muy por debajo, y el DSR es de 0,13
    • BQue es significativa al 95 %
    • CQue el DSR no se puede calcular con datos semanales
    • DQue el resultado es negativo
    Ver respuesta

    Respuesta A. Que, con la dispersión de los 20 intentos, el mejor esperado por azar ronda un Sharpe neto de 0,71: un 0,37 queda muy por debajo, y el DSR es de 0,13

    Incluso sin corregir, su PSR(0) es 0,89, por debajo de 0,95. Corregido por haber elegido la mejor de 20, el DSR cae a 0,13. Es un resultado compatible con cero.

  3. Con revision_analistas, el mismo ridge tiene un Sharpe neto de 17,7 y un DSR de 1,000 con cualquier N. ¿Qué se concluye?

    • AQue la estrategia es excelente
    • BQue el DSR está mal programado
    • CQue hay que declarar más intentos
    • DQue el DSR descuenta la selección entre intentos, no la información del futuro: una fuga pasa cualquier corrección estadística. Un Sharpe neto de 17 es la alarma
    Ver respuesta

    Respuesta D. Que el DSR descuenta la selección entre intentos, no la información del futuro: una fuga pasa cualquier corrección estadística. Un Sharpe neto de 17 es la alarma

    El DSR responde a «¿podría ser el mejor de N intentos sin ventaja?». Con una fuga la ventaja es real dentro del backtest, porque el backtest ve el futuro. Solo la auditoría point-in-time la descubre.

  4. ¿Qué hay que guardar de cada experimento para poder calcular el DSR y la PBO al final?

    • AEl Sharpe del mejor
    • BSolo los parámetros del modelo final
    • CLa configuración completa y la serie de rendimientos netos de cada variante, también de las descartadas, y el número total de variantes probadas (incluidas las de lecciones o cuadernos anteriores)
    • DNada: el DSR solo necesita el Sharpe final
    Ver respuesta

    Respuesta C. La configuración completa y la serie de rendimientos netos de cada variante, también de las descartadas, y el número total de variantes probadas (incluidas las de lecciones o cuadernos anteriores)

    El DSR necesita N y la dispersión de los Sharpes de todos los intentos; la PBO, la matriz completa de rendimientos. Lo que no se registra no se puede descontar.

  5. ¿Qué hacen las reglas de suavizado y de banda para que el neto mejore?

    • AAumentan el IC del modelo
    • BEliminan los costes
    • CCambian el universo
    • DReducen la rotación (del 265 % a un 86-144 % semanal) a costa de seguir la señal con retraso: pierden Sharpe bruto, pero ahorran más en costes de lo que pierden
    Ver respuesta

    Respuesta D. Reducen la rotación (del 265 % a un 86-144 % semanal) a costa de seguir la señal con retraso: pierden Sharpe bruto, pero ahorran más en costes de lo que pierden

    Con una señal que cambia poco de una semana a otra, operar cada cambio pequeño de rango sale caro. Suavizar o exigir una banda para entrar y salir conserva la mayor parte de la exposición con una fracción de las operaciones.

Ideas clave

  • El README se reproduce: oráculo con Sharpe bruto 1,78 y neto 0,38; ridge con todas las features, 1,25 bruto y −0,18 neto, con una rotación del 265 % semanal y 10 pb por lado.
  • La cuenta del coste es simple: rotación × 52 × coste por lado. El ridge soporta unos 8,8 pb por lado antes de perder.
  • Reducir la rotación (suavizar puntuaciones, bandas, rebalanceo cada 4 semanas) baja el Sharpe bruto y sube el neto. La mejor de 20 variantes llega a 0,37 neto.
  • Ese 0,37 tiene un error típico de ±0,30 en once años, un PSR(0) de 0,89 y un DSR de 0,13 con 20 intentos: compatible con cero.
  • El registro de experimentos es parte del resultado: sin la serie de cada variante no hay DSR ni PBO.
  • El DSR no ve fugas: el modelo con revision_analistas da un Sharpe neto de 17,7 y un DSR de 1,000.

Recursos

Documentación

Datos