---
title: "2.5.8 Checklist de backtest honesto"
description: "Lista de comprobaciones y auditor ejecutable antes del paper trading: ejecución, costes, fuera de muestra, DSR, PBO, meseta, capacidad y supervivencia."
url: "https://pythonparatrading.com/curso-python-trading/nivel-2/checklist-de-backtest-honesto/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-2/checklist-de-backtest-honesto.png"
---

# 2.5.8 · Checklist de «backtest honesto» antes de pasar a paper trading

> Un backtest no se aprueba por su Sharpe, sino por las pruebas que ha superado. Esta lección reúne las del módulo en una lista, programa un auditor que ejecuta ocho de ellas sobre un objeto de resultado y lo aplica a dos estrategias: una con un efecto plantado y un placebo optimizado que, mirado de una en una, parece casi igual de bueno.

- Curso: [Nivel 2: Estrategias sistemáticas y backtesting riguroso](https://pythonparatrading.com/curso-python-trading/nivel-2/)
- Módulo: 2.5 · Sesgos y validación
- Duración: 70 min
- Requisitos: [2.5.4 · Deflated Sharpe Ratio y Probability of Backtest Overfitting (PBO)](https://pythonparatrading.com/curso-python-trading/nivel-2/deflated-sharpe-ratio-y-pbo/), [2.5.6 · Sensibilidad de parámetros: mesetas frente a picos, robustez entre activos y periodos](https://pythonparatrading.com/curso-python-trading/nivel-2/sensibilidad-de-parametros-y-robustez/), [2.3.5 · Factores (value, calidad, baja volatilidad, tamaño) y estrategias de eventos](https://pythonparatrading.com/curso-python-trading/nivel-2/factores-y-estrategias-de-eventos/)

## Al terminar esta lección

- Usar una lista de comprobación de backtest honesto y saber qué lección del curso cubre cada punto.
- Representar un backtest como un objeto con todo lo que hace falta para auditarlo: rendimientos, pesos, la rejilla de todas las pruebas, el corte dentro y fuera de muestra y el universo.
- Programar ocho comprobaciones automáticas: ejecución en la barra siguiente, costes, fuera de muestra, Deflated Sharpe, PBO, meseta, capacidad y supervivencia.
- Interpretar un veredicto: por qué un falso positivo pasa varias pruebas y qué prueba lo delata, y qué queda para la revisión manual y el paper trading.

## Una puerta antes del dinero

Las lecciones de este módulo han presentado los errores de un backtest uno a uno: anticipación y supervivencia (2.5.1),
selección dentro de muestra (2.5.2 y 2.5.3), número de pruebas (2.5.4), azar del orden (2.5.5), picos de parámetros
(2.5.6) y escenarios que la muestra no tiene (2.5.7). Esta lección las convierte en una **puerta**: una lista que todo
backtest debe pasar antes de que una estrategia toque dinero, primero simulado y después real. Harvey y Liu (2015)[^1]
lo plantean de forma parecida: un resultado de backtest tiene que ajustarse por todo lo que se probó antes de llegar a
él.

```mermaid
flowchart TB
  B[Backtest candidato] --> A{Auditor}
  A -- falla --> I[Investigar más]
  A -- pasa --> M{Revisión manual}
  M -- falla --> I
  M -- pasa --> P[Paper trading]
  P --> C{¿Coincide?}
  C -- no --> I
  C -- sí --> R[Real, pequeño]
```

La puerta tiene dos partes. Una se puede automatizar: son pruebas que se calculan con los datos y el código del
backtest, y que conviene ejecutar siempre igual, sin decidir caso por caso. La otra exige criterio: la hipótesis, la
calidad de los datos, la revisión del código. Las dos están en la lista.

## La lista

| # | Comprobación | Pregunta | Cómo | Umbral orientativo | Auditor | Lección |
|---|---|---|---|---|---|---|
| 1 | Hipótesis previa | ¿De dónde sale la ventaja y quién pierde al otro lado? | Escrita antes de ver resultados | Una frase verificable | Manual | 2.2.1 |
| 2 | Diario de experimentos | ¿Cuántas cosas se probaron de verdad? | Registro de cada prueba, también las descartadas | Número total de pruebas | Manual | 2.2.4 |
| 3 | Universo point-in-time | ¿El universo incluye las empresas que desaparecieron? | Deslistados y su rentabilidad final | Ninguno fuera | Sí | 1.4.4 y 2.5.1 |
| 4 | Ejecución en la barra siguiente | ¿La señal usa solo datos pasados? | Truncado de datos y un periodo más de retardo | Truncado igual; Sharpe con retardo ≥ 50 % | Sí | 2.5.1 |
| 5 | Costes | ¿Sobrevive a costes peores de lo previsto? | Costes ×2 | Sharpe ≥ 0,5 | Sí | 2.4.7 |
| 6 | Fills | ¿Son realistas las órdenes limitadas, los stops y los huecos? | Modelo de fills | Sin fills imposibles | Manual | 2.4.8 |
| 7 | Fuera de muestra | ¿Aguanta lo elegido en datos no usados para elegir? | Corte o walk-forward | Fuera ≥ 50 % de dentro | Sí | 2.5.2 |
| 8 | Número de pruebas | ¿Supera el máximo esperable por azar? | Deflated Sharpe | DSR ≥ 0,95 | Sí | 2.5.4 |
| 9 | Selección | ¿Elegir el mejor sirve, o hace daño? | PBO (CSCV) y probabilidad de pérdida | Pérdida ≤ 10 % | Sí | 2.5.3 y 2.5.4 |
| 10 | Meseta | ¿Los parámetros vecinos dan algo parecido? | Vecinas del elegido | ≥ 70 % del elegido | Sí | 2.5.6 |
| 11 | Robustez | ¿Funciona en otros activos y periodos? | Por mercado, sector y tramo | Mayoría positiva | Manual | 2.5.6 |
| 12 | Capacidad | ¿Cabe el capital previsto? | Órdenes frente al volumen diario | p95 ≤ 10 % del volumen | Sí | 2.2.3 |
| 13 | Riesgo | ¿Qué drawdown y qué tamaño? | Bootstrap de bloques, abanico | Ruina aceptable | Manual | 2.5.5 |
| 14 | Estrés | ¿Dónde pierde? | Escenarios sintéticos | Pérdida tolerable | Manual | 2.5.7 |
| 15 | Reproducibilidad | ¿Otro puede obtener las mismas cifras? | Código versionado, datos con huella, semillas | Mismo resultado | Manual | 1.4.8 |

Los umbrales son orientativos: cada equipo fija los suyos, **antes** de ver los resultados y para todas las
estrategias por igual. Un umbral que se ajusta después para que una estrategia pase es otra forma de sobreajuste.

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

Los datos son sintéticos. La estrategia A explota primas **plantadas** en el universo del nivel 2 (momentum y value,
con sus coeficientes en el README); la B usa la `puntuacion_esg`, que el README describe como un placebo puro, un
AR(1) mensual independiente de todo. Que A pase y B no dice si el auditor distingue los dos casos, no que una
estrategia de value y momentum vaya a funcionar en mercados reales.

</aside>

## Dos backtests para auditar

Las dos estrategias son largo-corto mensuales sobre las 120 acciones ficticias del nivel 2: compran el tramo superior
de una puntuación y venden el inferior, equiponderadas, con los pesos fijados al cierre de cada mes y el rendimiento
del mes siguiente. Primero, los rendimientos mensuales. El detalle que importa es el mes en que una empresa deja de
cotizar: hay que incluir su última cotización **y** la rentabilidad de deslistado (−100 % en una quiebra), o el
backtest ignora la peor pérdida de cada quiebra (lección 1.4.4):

```python
import itertools
import numpy as np
import pandas as pd
from dataclasses import dataclass, field
from scipy import stats

aj = pd.read_parquet("universo2_ajustados.parquet")
px = aj.pivot(index="fecha", columns="ticker", values="cierre_ajustado")
fin_mes = px.index.to_series().groupby(px.index.to_period("M")).max()
mensual = px.loc[fin_mes.to_numpy()]
R = mensual.pct_change(fill_method=None)

# el mes de un deslistado: hasta la última cotización y luego el pago
eventos = pd.read_csv("universo2_eventos.csv", parse_dates=["fecha"])
deslist = eventos.dropna(subset=["rentabilidad_deslistado"])
for _, ev in deslist.iterrows():
    tk = ev["ticker"]
    ultima = px[tk].last_valid_index()
    mes = R.index[R.index >= ultima][0]
    antes = R.index[R.index < ultima][-1]
    R.loc[mes, tk] = (px.loc[ultima, tk] / mensual.loc[antes, tk]
                      * (1 + ev["rentabilidad_deslistado"]) - 1)
print(R.shape, f"{len(deslist)} deslistados con su rentabilidad final")
R.loc["2008-10", ["QXQT"]]
```
```output
(240, 120) 10 deslistados con su rentabilidad final
ticker      QXQT
fecha
2008-10-31  -1.0
```

Las características son las de `universo2_caracteristicas.parquet`, que el README define **point-in-time**: lo que se
sabía al cierre de cada mes. El value es el logaritmo del patrimonio neto sobre la capitalización (solo con patrimonio
positivo):

```python
car = pd.read_parquet("universo2_caracteristicas.parquet").set_index(
    ["fecha", "ticker"])
def panel(col):
    return car[col].unstack().reindex(R.index)

pn, cap = panel("patrimonio_neto"), panel("capitalizacion")
datos = {
    "R": R,
    "value": np.log(pn.where(pn > 0) / cap),
    "esg": panel("puntuacion_esg").astype(float),
    "horquilla": panel("horquilla_pb"),
    "adv_usd": panel("volumen_medio_21d") * panel("cierre"),
    "cotiza": panel("cierre").notna(),
}
datos["value"].iloc[-1].describe().round(2)
```
```output
count    110.00
mean      -0.54
std        0.68
min       -2.22
25%       -0.99
50%       -0.60
75%        0.00
max        0.81
Name: 2025-12-31 00:00:00, dtype: float64
```

El motor: puntuaciones normales por rangos, momentum que se salta el último mes, largo-corto por cuantiles y un coste
de media horquilla por unidad de rotación, con la `horquilla_pb` de cada acción y mes (lección 2.4.7). El `retardo`
permite ejecutar un mes más tarde, y lo usará el auditor.

```python
def z_rango(df):
    """Puntuación normal por rangos en cada fecha."""
    rk = df.rank(axis=1)
    n = df.notna().sum(axis=1)
    u = (rk - 0.5).div(n, axis=0).to_numpy()
    return pd.DataFrame(stats.norm.ppf(u), index=df.index,
                        columns=df.columns)

def momentum(R, L):
    """Rentabilidad de los meses m-L+1 a m-1 (se salta el último)."""
    with np.errstate(divide="ignore"):    # log1p(-1) = -inf: quiebra
        lr = np.log1p(R)
    return np.expm1(lr.shift(1).rolling(L - 1).sum())

def largo_corto(puntos, puede, q):
    """Largo el q superior y corto el q inferior, equiponderados."""
    rk = puntos.where(puede).rank(axis=1, pct=True)
    lar, cor = (rk > 1 - q).astype(float), (rk <= q).astype(float)
    return (lar.div(lar.sum(axis=1), axis=0)
            - cor.div(cor.sum(axis=1), axis=0)).fillna(0)

INICIO = "2007-08"   # la ventana más larga (18 meses) ya existe

def backtest(d, puntos, q, universo, coste=1.0, retardo=1):
    """Pesos al cierre del mes t; rendimiento del mes t + retardo."""
    puede = d["cotiza"] & d["cotiza"].columns.isin(list(universo))
    w = largo_corto(puntos, puede, q)
    bruto = (w.shift(retardo) * d["R"]).sum(axis=1)
    giro = (w - w.shift(1)).abs()
    pb = d["horquilla"].fillna(20) / 2 / 1e4      # media horquilla
    gasto = (giro * pb).sum(axis=1).shift(retardo) * coste
    return (bruto - gasto).loc[INICIO:], w.loc[INICIO:]

def sharpe(x):
    x = np.asarray(x, dtype=float)
    return x.mean() / x.std(ddof=1) * np.sqrt(12)
```

La **estrategia A** combina momentum y value. El investigador prueba seis ventanas de momentum y cinco cuantiles, 30
combinaciones, y elige la mejor en la primera mitad de los datos (de 2007-08 a 2016-07):

```python
def senal_vm(d, L):
    return z_rango(momentum(d["R"], L)) + z_rango(d["value"])

TODOS = set(R.columns)                       # universo point-in-time
rejilla_a = pd.DataFrame({
    (L, q): backtest(datos, senal_vm(datos, L), q, TODOS)[0]
    for L in (3, 6, 9, 12, 15, 18) for q in (0.1, 0.2, 0.3, 0.4, 0.5)})
CORTE = "2016-07"                            # dentro: 2007-08 a 2016-07
sr_in = rejilla_a.loc[:CORTE].apply(sharpe)
elegido_a = sr_in.idxmax()
print(f"Elegido dentro de muestra: L = {elegido_a[0]} meses, "
      f"q = {elegido_a[1]} (Sharpe {sr_in.max():.2f})")
sr_in.unstack().round(2)
```
```output
Elegido dentro de muestra: L = 6 meses, q = 0.4 (Sharpe 0.97)
     0.1   0.2   0.3   0.4   0.5
3   0.42  0.69  0.90  0.74  0.85
6   0.69  0.86  0.82  0.97  0.90
9   0.58  0.63  0.50  0.55  0.65
12  0.86  0.72  0.74  0.80  0.87
15  0.47  0.67  0.61  0.64  0.65
18  0.36  0.61  0.73  0.73  0.54
```

Elige 6 meses y el 40 %, con un Sharpe de 0,97 dentro de muestra, en una rejilla en la que las 30 combinaciones son
positivas: una meseta. La **estrategia B** es lo que sale de un proceso menos cuidadoso. El investigador prueba la
puntuación ESG suavizada con seis ventanas, con los dos signos (comprar las de mejor o las de peor puntuación) y cinco
cuantiles, 60 combinaciones, y lo hace sobre las empresas que **cotizan hoy**, porque es la lista que tenía a mano:

```python
def senal_esg(d, k, signo):
    return signo * d["esg"].rolling(k).mean()

sect = pd.read_csv("universo2_sectores.csv")
SUPERVIVIENTES = set(sect.loc[sect["ultima_sesion"].isna(), "ticker"])
rejilla_b = pd.DataFrame({
    (k, s, q): backtest(datos, senal_esg(datos, k, s), q,
                        SUPERVIVIENTES)[0]
    for k in (1, 2, 3, 6, 9, 12) for s in (1, -1)
    for q in (0.1, 0.2, 0.3, 0.4, 0.5)})
sr_in_b = rejilla_b.loc[:CORTE].apply(sharpe)
elegido_b = sr_in_b.idxmax()
print(f"{len(SUPERVIVIENTES)} supervivientes de {len(TODOS)}")
print(f"Elegido: media de {elegido_b[0]} meses, signo {elegido_b[1]}, "
      f"q = {elegido_b[2]} (Sharpe {sr_in_b.max():.2f})")
sr_in_b.unstack([1, 2]).round(2).iloc[:, :5]
```
```output
110 supervivientes de 120
Elegido: media de 9 meses, signo 1, q = 0.2 (Sharpe 0.53)
       1
     0.1   0.2   0.3   0.4   0.5
1   0.27  0.32  0.17  0.16  0.17
2   0.35  0.38  0.11  0.16  0.08
3   0.35  0.50  0.20  0.10  0.21
6   0.22  0.43  0.17  0.01  0.22
9   0.41  0.53  0.24  0.16  0.09
12  0.30  0.26  0.15  0.11 -0.00
```

Un Sharpe de 0,53 dentro de muestra, con un signo y una ventana elegidos a la vista de los resultados. Contado así, B
parece una estrategia más débil que A, pero no un disparate. El auditor debe decir si lo es.

## El objeto que se audita

Para auditar un backtest hacen falta más cosas que su curva de capital. El objeto `ResultadoBacktest` las guarda
todas: los rendimientos netos, los pesos, una función para volver a ejecutarlo con otros costes o retardos, la función
de la señal (para el truncado), la **rejilla completa** de todo lo que se probó (para el DSR, el PBO y la meseta), la
combinación elegida, el corte dentro y fuera de muestra, el número de pruebas, el universo y el capital previsto. Si
el investigador no puede rellenar un campo (no sabe cuántas pruebas hizo, no guardó la rejilla), eso ya es un
resultado de la auditoría.

```python
@dataclass
class ResultadoBacktest:
    nombre: str
    rend: pd.Series             # mensual, neto de costes
    pesos: pd.DataFrame         # al cierre de cada mes
    ejecutar: object            # (datos, coste, retardo) -> rend
    senal: object               # datos -> puntuaciones
    rejilla: pd.DataFrame       # TODAS las combinaciones probadas
    elegido: tuple
    corte: str                  # último mes dentro de muestra
    n_pruebas: int              # incluidas las que no se apuntaron
    universo: set
    fijos: tuple = ()           # ejes de la rejilla que no son orden
    capital: float = 50e6       # capital previsto (USD)

def resultado(nombre, senal, q, universo, rejilla, elegido, n,
              fijos=()):
    rend, w = backtest(datos, senal(datos), q, universo)
    return ResultadoBacktest(
        nombre, rend, w,
        lambda d, c=1.0, t=1: backtest(d, senal(d), q, universo,
                                       c, t)[0],
        senal, rejilla, elegido, CORTE, n, universo, fijos)

L, q = elegido_a
A = resultado("value + momentum", lambda d: senal_vm(d, L), q,
              TODOS, rejilla_a, elegido_a, rejilla_a.shape[1])
k, s, qb = elegido_b
B = resultado("placebo ESG", lambda d: senal_esg(d, k, s), qb,
              SUPERVIVIENTES, rejilla_b, elegido_b, rejilla_b.shape[1],
              fijos=(1,))                    # el signo no tiene vecinos
for res in (A, B):
    print(f"{res.nombre}: {len(res.rend)} meses, Sharpe "
          f"{sharpe(res.rend):.2f}, {res.n_pruebas} pruebas")
```
```output
value + momentum: 221 meses, Sharpe 1.12, 30 pruebas
placebo ESG: 221 meses, Sharpe 0.54, 60 pruebas
```

`fijos=(1,)` dice al auditor que el segundo eje de la rejilla de B (el signo) no tiene orden: comprar las de mejor
puntuación no es «vecino» de comprar las de peor.

## Las ocho pruebas automáticas

Cada prueba devuelve un valor, su umbral y si pasa. Las tres primeras vuelven a ejecutar el backtest.

**Ejecución en la barra siguiente.** Dos comprobaciones. El **truncado**: se borran todos los datos posteriores a una
fecha y se recalcula la señal; si la puntuación de esa fecha cambia, la señal usa el futuro. Y el **retardo**: con un
mes más de retraso en la ejecución, una ventaja de horizonte mensual debe perder algo, no desaparecer. **Costes**: el
backtest con el doble de costes. **Fuera de muestra**: el Sharpe de la combinación elegida después del corte frente al
de antes.

```python
def prueba(valor, umbral, pasa, texto):
    return {"valor": valor, "umbral": umbral, "pasa": bool(pasa),
            "detalle": texto}

def p_ejecucion(res, fechas=("2010-06", "2015-12", "2020-03")):
    """Truncado: la señal de d no cambia si se borra lo posterior a d.
    Retardo: con un mes más de retraso, la ventaja no desaparece."""
    completa = res.senal(datos)
    iguales = all(
        np.allclose(res.senal({c: v.loc[:f] for c, v in datos.items()})
                    .loc[f].to_numpy(), completa.loc[f].to_numpy(),
                    equal_nan=True) for f in fechas)
    s1, s2 = sharpe(res.rend), sharpe(res.ejecutar(datos, 1.0, 2))
    return prueba(s2 / s1, ">= 0,5 y truncado igual",
                  iguales and s2 >= 0.5 * s1,
                  f"truncado {'igual' if iguales else 'DISTINTO'}; "
                  f"Sharpe t+1 {s1:.2f}, t+2 {s2:.2f}")

def p_costes(res):
    s = sharpe(res.ejecutar(datos, 2.0, 1))
    return prueba(s, ">= 0,5", s >= 0.5,
                  f"Sharpe con costes x2 {s:.2f}")

def p_fuera(res):
    r = res.rejilla[res.elegido]
    s_in, s_out = sharpe(r.loc[:res.corte]), sharpe(r.loc[res.corte:]
                                                     .iloc[1:])
    return prueba(s_out / s_in, ">= 0,5", s_out >= 0.5 * s_in > 0,
                  f"dentro {s_in:.2f}, fuera {s_out:.2f}")

for res in (A, B):
    for f in (p_ejecucion, p_costes, p_fuera):
        print(f"{res.nombre:>16} | {f.__name__:<11} | "
              f"{f(res)['detalle']}")
```
```output
value + momentum | p_ejecucion | truncado igual; Sharpe t+1 1.12, t+2 0.94
value + momentum | p_costes    | Sharpe con costes x2 1.08
value + momentum | p_fuera     | dentro 0.97, fuera 1.27
     placebo ESG | p_ejecucion | truncado igual; Sharpe t+1 0.54, t+2 0.36
     placebo ESG | p_costes    | Sharpe con costes x2 0.53
     placebo ESG | p_fuera     | dentro 0.53, fuera 0.56
```

Las dos estrategias pasan las tres. A pierde 0,18 de Sharpe con un mes de retraso (de 1,12 a 0,94), casi nada con el
doble de costes (1,08) y mejora fuera de muestra (de 0,97 a 1,27). B también pasa: 0,36 con retraso, 0,53 con el doble
de costes y 0,56 fuera de muestra frente a 0,53 dentro. Esto merece una pausa. El placebo no tiene ninguna ventaja por
construcción, y aun así su Sharpe fuera de muestra es tan bueno como dentro. La razón: en estos datos, por azar, la
puntuación ESG está relacionada con las rentabilidades a lo largo de toda la muestra, y una relación casual estable no
se rompe al partir la muestra en dos. La comprobación fuera de muestra detecta el sobreajuste a una época; no detecta
un efecto espurio que dura toda la historia disponible ni la selección entre muchas pruebas.

**Deflated Sharpe** (lección 2.5.4): la probabilidad de que el Sharpe verdadero supere al máximo que cabría esperar
por azar entre $N$ pruebas,

$$
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]
$$

con $V[\widehat{SR}_n]$ la varianza de los Sharpes de todas las pruebas y $\gamma$ la constante de Euler-Mascheroni.
**PBO** por validación cruzada combinatoria simétrica (CSCV): la muestra se parte en 10 bloques, se toman las 252
formas de elegir 5 como «dentro», y en cada una se mira en qué puesto queda fuera la mejor de dentro. El auditor
calcula también la **probabilidad de pérdida**: en qué fracción de las particiones la elegida pierde fuera.

```python
EULER = np.euler_gamma    # constante de Euler-Mascheroni

def p_dsr(res):
    """Deflated Sharpe Ratio (Bailey y López de Prado, 2014)."""
    r = res.rend.to_numpy()
    T, sr = len(r), r.mean() / r.std(ddof=1)        # por mes
    g3, g4 = stats.skew(r), stats.kurtosis(r, fisher=False)
    pruebas = res.rejilla.apply(lambda c: c.mean() / c.std(ddof=1))
    N = res.n_pruebas
    sr0 = np.sqrt(pruebas.var(ddof=1)) * (
        (1 - EULER) * stats.norm.ppf(1 - 1 / N)
        + EULER * stats.norm.ppf(1 - 1 / (N * np.e)))
    z = (sr - sr0) * np.sqrt(T - 1) / np.sqrt(
        1 - g3 * sr + (g4 - 1) / 4 * sr ** 2)
    dsr = stats.norm.cdf(z)
    return prueba(dsr, ">= 0,95", dsr >= 0.95,
                  f"Sharpe {sr * np.sqrt(12):.2f} frente a un máximo "
                  f"esperado por azar de {sr0 * np.sqrt(12):.2f}")

def p_pbo(res, S=10):
    """CSCV: la mitad de los bloques dentro, la otra mitad fuera."""
    M = res.rejilla.to_numpy()
    T, N = M.shape
    bloques = np.array_split(np.arange(T), S)
    logits, pierde = [], []
    for dentro in itertools.combinations(range(S), S // 2):
        i = np.concatenate([bloques[j] for j in dentro])
        o = np.concatenate([bloques[j] for j in range(S)
                            if j not in dentro])
        sr_i = M[i].mean(0) / M[i].std(0, ddof=1)
        sr_o = M[o].mean(0) / M[o].std(0, ddof=1)
        n = sr_i.argmax()
        w = stats.rankdata(sr_o)[n] / (N + 1)
        logits.append(np.log(w / (1 - w)))
        pierde.append(sr_o[n] <= 0)
    pbo, p_perd = np.mean(np.array(logits) <= 0), np.mean(pierde)
    return prueba(p_perd, "pérdida <= 10 %", p_perd <= 0.10,
                  f"PBO {pbo:.2f}; el elegido pierde fuera en el "
                  f"{p_perd:.0%} de las {len(logits)} particiones")

for res in (A, B):
    for f in (p_dsr, p_pbo):
        print(f"{res.nombre:>16} | {f.__name__:<5} | "
              f"{f(res)['detalle']}")
```
```output
value + momentum | p_dsr | Sharpe 1.12 frente a un máximo esperado por azar de 0.30
value + momentum | p_pbo | PBO 0.55; el elegido pierde fuera en el 0% de las 252 particiones
     placebo ESG | p_dsr | Sharpe 0.54 frente a un máximo esperado por azar de 0.85
     placebo ESG | p_pbo | PBO 0.14; el elegido pierde fuera en el 17% de las 252 particiones
```

*Fig. — Deflated Sharpe: Sharpe medido de cada estrategia (punto) frente al máximo que cabría esperar por azar con su número de pruebas (marca).* Dos filas. Value + momentum: Sharpe medido 1,12 frente a un máximo esperado por azar de 0,30 con 30 pruebas. Placebo ESG, destacado: Sharpe medido 0,54 frente a 0,85 con 60 pruebas, que incluyen los dos signos de la señal; su medido queda por debajo de lo que daría el azar.

Aquí se separan. El Sharpe de A, 1,12, está muy por encima del 0,30 que darían 30 pruebas sin ventaja con la
dispersión de su rejilla: DSR de 1,00. El de B, 0,54, está por **debajo** del 0,85 que cabe esperar como máximo de 60
pruebas sin ventaja, porque su rejilla tiene mucha dispersión (incluye los dos signos): DSR de 0,09. Contadas todas
las pruebas, el placebo no es distinguible del azar.

El PBO da un resultado que confunde si se lee solo: 0,55 para A y 0,14 para B. En la rejilla de A todas las
combinaciones son buenas y se parecen, así que cuál queda primera fuera de muestra es una lotería y el PBO ronda 0,5;
pero la elegida **no pierde** fuera en ninguna de las 252 particiones. En B, la relación casual es estable y la mejor
de dentro tiende a ser buena fuera (PBO bajo), pero pierde en el 17 % de las particiones. Por eso el auditor decide
con la probabilidad de pérdida y enseña el PBO como contexto, igual que recomienda la lección 2.5.4.

**Meseta** (lección 2.5.6): la media de las vecinas de la combinación elegida en la rejilla de dentro de muestra,
frente a la elegida. **Capacidad**: el capital al que el percentil 95 de las órdenes de rebalanceo llega al 10 % del
volumen medio diario de la acción, frente al capital previsto (50 M USD). **Supervivencia**: si el universo deja fuera
algún valor que dejó de cotizar.

```python
def p_meseta(res):
    """Vecinas del elegido en la rejilla de dentro de muestra."""
    sr_in = res.rejilla.loc[:res.corte].apply(sharpe)
    ejes = [sorted({k[i] for k in sr_in.index})
            for i in range(len(res.elegido))]
    pos = {k: [ejes[i].index(v) for i, v in enumerate(k)]
           for k in sr_in.index}
    yo = pos[res.elegido]
    vec = [sr_in[k] for k, p in pos.items() if k != res.elegido
           and max(abs(a - b) for a, b in zip(p, yo)) <= 1
           and all(k[i] == res.elegido[i] for i in res.fijos)]
    ratio = np.mean(vec) / sr_in[res.elegido]
    return prueba(ratio, ">= 0,7", ratio >= 0.7,
                  f"{len(vec)} vecinas: media {np.mean(vec):.2f} "
                  f"frente a {sr_in[res.elegido]:.2f}")

def p_capacidad(res, limite=0.10):
    """Capital al que el percentil 95 de las órdenes es el 10 %
    del volumen diario medio de la acción."""
    giro = (res.pesos - res.pesos.shift(1)).abs()
    part = (giro / datos["adv_usd"].loc[INICIO:]).stack()
    p95 = part[part > 0].quantile(0.95)
    cap = limite / p95
    return prueba(cap / 1e6, f">= {res.capital / 1e6:.0f} M USD",
                  cap >= res.capital,
                  f"capacidad {cap / 1e6:.0f} M USD")

def p_supervivencia(res):
    """¿El universo incluye los valores que dejaron de cotizar?"""
    fuera = set(deslist["ticker"]) - res.universo
    return prueba(len(fuera), "0 excluidos", not fuera,
                  f"{len(fuera)} deslistados fuera del universo")

for res in (A, B):
    for f in (p_meseta, p_capacidad, p_supervivencia):
        print(f"{res.nombre:>16} | {f.__name__:<15} | "
              f"{f(res)['detalle']}")
```
```output
value + momentum | p_meseta        | 8 vecinas: media 0.74 frente a 0.97
value + momentum | p_capacidad     | capacidad 54 M USD
value + momentum | p_supervivencia | 0 deslistados fuera del universo
     placebo ESG | p_meseta        | 8 vecinas: media 0.27 frente a 0.53
     placebo ESG | p_capacidad     | capacidad 56 M USD
     placebo ESG | p_supervivencia | 10 deslistados fuera del universo
```

A pasa las tres: sus vecinas conservan el 76 % del Sharpe de la elegida (0,74 frente a 0,97), cabe un capital de 54 M
USD y su universo incluye los 10 deslistados. B falla la meseta (0,27 frente a 0,53: la ventana y el cuantil elegidos
destacan sobre sus vecinos) y la supervivencia (los 10 deslistados están fuera), y pasa la capacidad (56 M USD).

## El veredicto

El auditor completo ejecuta las ocho pruebas y solo declara apta una estrategia que las pasa todas:

```python
PRUEBAS = {"ejecución t+1": p_ejecucion, "costes x2": p_costes,
           "fuera de muestra": p_fuera, "Deflated Sharpe": p_dsr,
           "PBO y pérdida": p_pbo, "meseta": p_meseta,
           "capacidad": p_capacidad, "supervivencia": p_supervivencia}

def auditar(res):
    filas = {n: f(res) for n, f in PRUEBAS.items()}
    tabla = pd.DataFrame(filas).T[["valor", "umbral", "pasa"]]
    veredicto = "APTA" if tabla["pasa"].all() else "NO APTA"
    return tabla, veredicto

informe = {}
for res in (A, B):
    tabla, veredicto = auditar(res)
    informe[res.nombre] = tabla
    print(f"{res.nombre}: {veredicto} "
          f"({tabla['pasa'].sum()} de {len(tabla)} pruebas)")
pd.concat({n: t["pasa"].map({True: "pasa", False: "FALLA"})
           for n, t in informe.items()}, axis=1)
```
```output
value + momentum: APTA (8 de 8 pruebas)
placebo ESG: NO APTA (4 de 8 pruebas)
                 value + momentum placebo ESG
ejecución t+1                pasa        pasa
costes x2                    pasa        pasa
fuera de muestra             pasa        pasa
Deflated Sharpe              pasa       FALLA
PBO y pérdida                pasa       FALLA
meseta                       pasa       FALLA
capacidad                    pasa        pasa
supervivencia                pasa       FALLA
```

La figura de la cabecera resume el resultado. A, la estrategia con el efecto plantado, pasa las ocho. B, el placebo,
pasa cuatro y falla cuatro, y ninguna de las que falla habría bastado por sí sola si se hubiera mirado aislada: el
DSR exige saber cuántas pruebas se hicieron, la meseta exige guardar la rejilla, la supervivencia exige conocer el
universo. El informe completo de B:

```python
t = informe["placebo ESG"].copy()
t["valor"] = t["valor"].astype(float).round(2)
t
```
```output
                  valor                   umbral   pasa
ejecución t+1      0.66  >= 0,5 y truncado igual   True
costes x2          0.53                   >= 0,5   True
fuera de muestra   1.05                   >= 0,5   True
Deflated Sharpe    0.09                  >= 0,95  False
PBO y pérdida      0.17          pérdida <= 10 %  False
meseta             0.51                   >= 0,7  False
capacidad         56.04              >= 50 M USD   True
supervivencia     10.00              0 excluidos  False
```

La prueba de supervivencia es de procedimiento: falla aunque el efecto sobre el resultado fuera pequeño, porque un
backtest con supervivientes no puede reproducirse en tiempo real, cuando no se sabe qué empresas van a desaparecer. En
este caso, además, el universo de supervivientes inflaba el placebo:

```python
con_todos, _ = backtest(datos, senal_esg(datos, k, s), qb, TODOS)
print(f"Placebo con supervivientes: {sharpe(B.rend):.2f}; "
      f"con el universo completo: {sharpe(con_todos):.2f}")
```
```output
Placebo con supervivientes: 0.54; con el universo completo: 0.43
```

*Fig. — Crecimiento acumulado, en logaritmo, de las dos estrategias auditadas, netas de costes; la línea vertical separa el periodo de elección (hasta julio de 2016) del de comprobación.* Dos curvas de 2007 a 2025. Value + momentum llega a 0,67 en el corte de 2016 y a 1,50 al final. El placebo ESG, destacado, llega a 0,42 en el corte y sigue subiendo hasta 0,82: fuera de muestra también gana, aunque su ventaja es nula por construcción.

La figura muestra el problema de juzgar por la curva: el placebo, en ámbar, sube también después del corte. Visto solo,
y con el universo de supervivientes, es una estrategia «que funciona fuera de muestra». Las pruebas que la delatan no
se ven en la curva.

## Lo que el auditor no ve, y el paper trading

Ocho pruebas automáticas cubren una parte de la lista. El resto exige criterio, y conviene hacerlo por escrito:

- **Hipótesis** (lección 2.2.1): una explicación de por qué existe la ventaja, escrita antes de mirar resultados. B no
  la tenía: «las empresas con mejor ESG suben» no dice quién pierde al otro lado ni por qué el mercado no lo ha
  corregido.
- **Datos**: errores, huecos, ajustes por splits y dividendos, zonas horarias (módulo 1.4).
- **Código**: revisión por otra persona, pruebas unitarias del motor, comparación con un motor independiente (lecciones
  2.4.1 y 2.4.2).
- **Riesgo y estrés** (lecciones 2.5.5 y 2.5.7): drawdown esperable, tamaño, escenarios donde pierde.

Y después, el **paper trading**: la estrategia corre en tiempo real, con datos en vivo y órdenes simuladas, durante
un periodo fijado de antemano. No sirve para medir el Sharpe (unos meses no bastan, lección 2.5.5) sino para comprobar
que lo que el backtest supone ocurre: que la señal calculada en vivo coincide con la que daría el backtest sobre los
mismos datos, que los fills y los costes reales se parecen a los modelados y que la operativa (datos que llegan
tarde, órdenes rechazadas, festivos) no rompe nada. Las diferencias entre paper y backtest son el último hallazgo de
la auditoría; si son grandes, se vuelve a investigar. Solo después viene el dinero real, con un tamaño pequeño.

## Ejercicios

1. Un compañero calcula el momentum con `np.log1p(R).shift(-1)` por error (un desplazamiento hacia atrás). Construye
   su resultado con el resto igual que la estrategia A y pásale la prueba de ejecución.

**Solución.**

```python
def momentum_malo(R, L):
    with np.errstate(divide="ignore"):
        lr = np.log1p(R)
    return np.expm1(lr.shift(-1).rolling(L - 1).sum())

def senal_mala(d):
    return z_rango(momentum_malo(d["R"], L)) + z_rango(d["value"])

M = resultado("con anticipación", senal_mala, q, TODOS, rejilla_a,
              elegido_a, rejilla_a.shape[1])
print(f"Sharpe: {sharpe(M.rend):.2f}")
print(p_ejecucion(M)["detalle"])
```
```output
Sharpe: 8.79
truncado DISTINTO; Sharpe t+1 8.79, t+2 1.24
```

Con el error, el momentum de cada mes incluye la rentabilidad del mes siguiente, y el Sharpe sube a 8,79: un número
que ya debería bastar como alarma. La prueba de truncado lo confirma: al borrar los datos posteriores a cada fecha, la
puntuación cambia. La de retardo también lo señala, porque con un mes más de retraso el Sharpe cae a 1,24 (de 8,79),
muy por debajo del 50 %. Un error de anticipación casi nunca es tan evidente; por eso la prueba de truncado se ejecuta
siempre, también cuando el Sharpe parece razonable.

2. Repite la estrategia A sobre los supervivientes y audítala. ¿Qué prueba falla y cuánto cambia el Sharpe?

**Solución.**

```python
rejilla_s = pd.DataFrame({
    (Ls, qs): backtest(datos, senal_vm(datos, Ls), qs,
                       SUPERVIVIENTES)[0]
    for Ls in (3, 6, 9, 12, 15, 18)
    for qs in (0.1, 0.2, 0.3, 0.4, 0.5)})
S = resultado("A con supervivientes", lambda d: senal_vm(d, L), q,
              SUPERVIVIENTES, rejilla_s, elegido_a, 30)
tabla_s, veredicto_s = auditar(S)
print(f"Sharpe {sharpe(S.rend):.2f} frente a {sharpe(A.rend):.2f}: "
      f"{veredicto_s}")
print(tabla_s.loc[~tabla_s["pasa"].astype(bool)].index.tolist())
```
```output
Sharpe 1.18 frente a 1.12: NO APTA
['supervivencia']
```

Sobre los supervivientes el Sharpe apenas cambia (1,18 frente a 1,12) y solo falla la prueba de supervivencia. La
estrategia sigue teniendo su efecto plantado, pero el procedimiento ya no es posible en tiempo real, y el auditor la
declara no apta hasta que se rehaga con el universo point-in-time. Que la diferencia sea pequeña aquí no garantiza
nada: en el placebo, el mismo error subía el Sharpe de 0,43 a 0,54, y en una estrategia que compre empresas en
dificultades puede cambiar el signo del resultado.

3. ¿Qué tendría que añadir el auditor para cubrir la robustez entre activos y periodos (punto 11 de la lista)? Describe
   la prueba, su umbral y qué campos nuevos necesitaría el objeto `ResultadoBacktest`.

**Solución.**

Necesitaría los rendimientos por activo (o por grupo: sector, país, clase de activo) de la combinación elegida, y un
campo con la partición temporal (por ejemplo, tramos de cinco años). La prueba calcularía el Sharpe en cada grupo y en
cada tramo y pasaría si la mayoría son positivos (por ejemplo, al menos dos tercios de los grupos y de los tramos) y
si ningún grupo explica por sí solo la mayor parte del resultado. Como en la lección 2.5.6, el umbral tiene que
fijarse dentro del dominio de la hipótesis: si la ventaja solo debería existir en un tipo de activo, la prueba se
aplica a los activos de ese tipo. Y, como todos los umbrales, se fija antes de ver los resultados.

[^1]: Harvey, C. R. y Liu, Y. (2015), «Backtesting», The Journal of Portfolio Management 42(1), 13-28,
    [doi:10.3905/jpm.2015.42.1.013](https://doi.org/10.3905/jpm.2015.42.1.013).

## Autoevaluación

1. El placebo ESG pasa la prueba fuera de muestra: Sharpe de 0,53 dentro y de 0,56 fuera. ¿Por qué no basta?

   - A. Porque el periodo fuera de muestra es demasiado corto
   - B. Porque una relación casual que se mantiene en toda la muestra también se mantiene fuera de ella: la comprobación fuera de muestra detecta el sobreajuste a una época, no un efecto espurio estable ni la selección entre 60 pruebas
   - C. Porque la prueba está mal programada
   - D. Sí basta: el placebo es una estrategia válida

   *Respuesta B: Porque una relación casual que se mantiene en toda la muestra también se mantiene fuera de ella: la comprobación fuera de muestra detecta el sobreajuste a una época, no un efecto espurio estable ni la selección entre 60 pruebas.* La puntuación ESG es independiente de todo por construcción, pero en estos datos su relación con las rentabilidades es positiva por azar en los dos periodos. Lo delatan el Deflated Sharpe (0,09: su Sharpe de 0,54 queda por debajo del máximo esperado por azar con 60 pruebas, 0,85), la meseta y la probabilidad de pérdida fuera de muestra.

2. La estrategia value + momentum tiene un PBO de 0,55 y el placebo, de 0,14. ¿Cómo se explica?

   - A. El placebo está menos sobreajustado que la estrategia real
   - B. En la rejilla de value + momentum todas las combinaciones son buenas y parecidas: cuál queda primera fuera de muestra es azar y el PBO ronda 0,5, sin que la elección haga daño (el elegido no pierde en ninguna partición). Por eso el PBO se lee con la probabilidad de pérdida
   - C. Hay un error en el cálculo de los rangos
   - D. El PBO solo funciona con datos diarios

   *Respuesta B: En la rejilla de value + momentum todas las combinaciones son buenas y parecidas: cuál queda primera fuera de muestra es azar y el PBO ronda 0,5, sin que la elección haga daño (el elegido no pierde en ninguna partición). Por eso el PBO se lee con la probabilidad de pérdida.* El PBO mide si elegir el mejor dentro de muestra sirve para quedar por encima de la mediana fuera. En una meseta, todos son igual de buenos y elegir no aporta: PBO ≈ 0,5. Lo que importa entonces es si el elegido pierde fuera: 0 % de las 252 particiones en la estrategia real, 17 % en el placebo.

3. ¿Qué detecta la prueba de truncado de la señal?

   - A. Que los costes están bien calculados
   - B. Que la puntuación de una fecha usa datos posteriores: si al borrar todo lo que viene después de la fecha la puntuación cambia, la señal mira al futuro
   - C. Que la estrategia tiene capacidad suficiente
   - D. Que el universo incluye los deslistados

   *Respuesta B: Que la puntuación de una fecha usa datos posteriores: si al borrar todo lo que viene después de la fecha la puntuación cambia, la señal mira al futuro.* Es la prueba directa de anticipación. En el ejercicio 1, un `shift(-1)` por error da un Sharpe de 8,79; la prueba de truncado lo marca como distinto. Una prueba de retardo (ejecutar un periodo más tarde) complementa: una ventaja que desaparece con un mes de retraso es sospechosa.

4. La estrategia value + momentum recalculada sobre los supervivientes tiene un Sharpe de 1,18 en lugar de 1,12, y el auditor la declara no apta. ¿Por qué falla si el Sharpe es parecido?

   - A. Porque el Sharpe ha subido
   - B. Porque su universo excluye los 10 valores que dejaron de cotizar: el resultado no puede reproducirse en tiempo real, cuando no se sabe qué empresas van a desaparecer. La prueba es de procedimiento, no de resultado
   - C. Porque la meseta cambia
   - D. Porque los costes son mayores

   *Respuesta B: Porque su universo excluye los 10 valores que dejaron de cotizar: el resultado no puede reproducirse en tiempo real, cuando no se sabe qué empresas van a desaparecer. La prueba es de procedimiento, no de resultado.* Un backtest con supervivientes usa información del futuro (quién sobrevive), aunque en un caso concreto el efecto sobre el Sharpe sea pequeño. En el placebo, el universo de supervivientes sube el Sharpe de 0,43 a 0,54. La prueba no pregunta cuánto cambia el resultado, sino si el procedimiento es posible en tiempo real.

5. La estrategia value + momentum pasa las ocho pruebas. ¿Qué sigue?

   - A. Operarla con todo el capital previsto
   - B. La revisión de lo que el auditor no ve (hipótesis, datos, código, estrés) y un periodo de paper trading que compare señales, fills y costes con lo que dice el backtest, antes de operar en real con un tamaño pequeño
   - C. Optimizar más parámetros para subir el Sharpe
   - D. Nada: está demostrado que funciona

   *Respuesta B: La revisión de lo que el auditor no ve (hipótesis, datos, código, estrés) y un periodo de paper trading que compare señales, fills y costes con lo que dice el backtest, antes de operar en real con un tamaño pequeño.* El auditor es una puerta necesaria, no suficiente. En los datos del curso, además, la estrategia gana porque el efecto está plantado; en datos reales, pasar el auditor solo dice que el backtest no tiene los errores que el auditor busca.

## Ideas clave

- Un backtest honesto se demuestra con pruebas, no con un Sharpe: ejecución en la barra siguiente, costes, fuera de muestra, número de pruebas (DSR), PBO y pérdida, meseta, capacidad y universo point-in-time.
- Las pruebas se programan sobre un objeto de resultado que guarda todo lo que hace falta: rendimientos, pesos, la rejilla completa de lo probado, el corte dentro y fuera de muestra, el universo y el capital previsto.
- La estrategia con efecto plantado pasa las ocho; el placebo ESG optimizado pasa cuatro (ejecución, costes, fuera de muestra y capacidad) y falla el Deflated Sharpe (0,09), la pérdida fuera de muestra (17 %), la meseta (0,51) y la supervivencia.
- Ninguna prueba sola basta: cada una detecta un error distinto, y un falso positivo estable en el tiempo puede pasar la comprobación fuera de muestra y tener un PBO bajo.
- El auditor no sustituye a la revisión manual (hipótesis, datos, código, estrés) ni al paper trading, que comprueba que señales, fills y costes en tiempo real coinciden con el backtest.

## Recursos

### Research

- [Quant Research](https://pythonparatrading.com/quant-research/) · Research-0023 · 8 min

### Documentación

- [Python: dataclasses](https://docs.python.org/3/library/dataclasses.html) · docs.python.org

### Libros

- [Bailey, D. H. y López de Prado, M. (2014): The Deflated Sharpe Ratio. The Journal of Portfolio Management 40(5), 94-107](https://doi.org/10.3905/jpm.2014.40.5.094) · doi.org
- [Bailey, D. H., Borwein, J., López de Prado, M. y Zhu, Q. J. (2016): The probability of backtest overfitting. The Journal of Computational Finance](https://doi.org/10.21314/JCF.2016.322) · doi.org
- [Harvey, C. R. y Liu, Y. (2015): Backtesting. The Journal of Portfolio Management 42(1), 13-28](https://doi.org/10.3905/jpm.2015.42.1.013) · doi.org

### Datos

- [Datos del nivel 2: características mensuales point-in-time del universo (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/universo2_caracteristicas.parquet) · Descarga · universo2_caracteristicas.parquet

Lección anterior: [2.5.7 · Datos sintéticos para pruebas de estrés: GBM, GARCH y bootstrapping](https://pythonparatrading.com/curso-python-trading/nivel-2/datos-sinteticos-para-pruebas-de-estres/)

Lección siguiente: [2.6.1 · CAGR, Sharpe, Sortino, Calmar, Omega, drawdown y Ulcer Index](https://pythonparatrading.com/curso-python-trading/nivel-2/metricas-de-rentabilidad-y-riesgo/)
