Lección 2.2.4 · 60 min

Diario de investigación y registro de experimentos contra el data snooping

Cada vez que se mira un backtest y se cambia algo, se hace una prueba más, y el mejor resultado de muchas pruebas siempre parece mejor de lo que es. Esta lección construye un registro de experimentos que no se puede reescribir sin que se note, lo usa para contar las pruebas y mide sobre ruido puro cuánto sube el mejor Sharpe al probar 200 combinaciones.

Fig. — Datos sin ventaja: mejor Sharpe de selección encontrado tras n pruebas, media de 50 historias de ruido con 200 cruces de medias en orden aleatorio, frente al máximo esperado de n pruebas independientes
Lecciones del nivel 2
  1. 2.1 · Estadística y econometría para trading

    1. 2.1.1 Contrastes de hipótesis, p-valores, bootstrap y regresión con errores robustos
    2. 2.1.2 Estacionariedad: ADF, KPSS, diferenciación y diferenciación fraccional
    3. 2.1.3 Modelos ARMA y ARIMA: autocorrelación simple (ACF) y parcial (PACF)
    4. 2.1.4 Modelos de volatilidad: ARCH, GARCH, EGARCH y GJR con la librería arch
    5. 2.1.5 Cointegración: Engle-Granger, Johansen y vida media de la reversión
    6. 2.1.6 Filtro de Kalman para hedge ratios dinámicos
    7. 2.1.7 Detección de regímenes: modelos de Markov ocultos y cambios estructurales
    8. 2.1.8 El problema de las pruebas múltiples: Bonferroni, FDR y el «factor zoo»
  2. 2.2 · Anatomía de un sistema de trading

    1. 2.2.1 Hipótesis de ventaja: riesgo, comportamiento, estructura e información
    2. 2.2.2 Universo, señal, filtro, entrada, salida, sizing y gestión del riesgo
    3. 2.2.3 Frecuencias: intradía, swing y posicional, y la capacidad de una estrategia
    4. 2.2.4 Diario de investigación y registro de experimentos contra el data snooping
  3. 2.3 · Familias de estrategias

    1. 2.3.1 Seguimiento de tendencia y momentum
    2. 2.3.2 Reversión a la media: RSI y Bollinger, reversión intradía y huecos
    3. 2.3.3 Pairs trading y arbitraje estadístico: distancia, cointegración y Kalman
    4. 2.3.4 Carry en divisas, futuros y bonos; estacionalidad y efectos calendario
    5. 2.3.5 Factores (value, calidad, baja volatilidad, tamaño) y estrategias de eventos
    6. 2.3.6 Introducción al market making: inventario, spread y selección adversa (Avellaneda-Stoikov)
    7. 2.3.7 Estrategias específicas de cripto: momentum, arbitraje de funding y basis
  4. 2.4 · Backtesting: motores y paradigmas

    1. 2.4.1 Backtest vectorizado desde cero: matrices, desfase, costes y límites
    2. 2.4.2 Backtest por eventos desde cero: bucle, cartera, órdenes y fills
    3. 2.4.3 vectorbt: barridos masivos de parámetros y simulación de carteras
    4. 2.4.4 backtesting.py y backtrader: dos motores didácticos y su estado
    5. 2.4.5 NautilusTrader: arquitectura, datos, estrategias y el mismo código en backtest y en real
    6. 2.4.6 LEAN/QuantConnect y Zipline Reloaded; bt para asignación de activos
    7. 2.4.7 Modelado de costes: comisiones, horquilla, impacto, financiación y préstamo de títulos
    8. 2.4.8 Modelado de fills: órdenes limitadas, cola, fills parciales, huecos y stops
  5. 2.5 · Sesgos y validación

    1. 2.5.1 Sesgos de backtest: anticipación, supervivencia, selección y narrativa
    2. 2.5.2 In-sample y out-of-sample: walk-forward anclado y móvil
    3. 2.5.3 Validación cruzada combinatoria purgada para estrategias
    4. 2.5.4 Deflated Sharpe Ratio y Probability of Backtest Overfitting (PBO)
    5. 2.5.5 Monte Carlo: remuestreo de trades, bootstrap de bloques y permutaciones de señales
    6. 2.5.6 Sensibilidad de parámetros: mesetas frente a picos, robustez entre activos y periodos
    7. 2.5.7 Datos sintéticos para pruebas de estrés: GBM, GARCH y bootstrapping
    8. 2.5.8 Checklist de «backtest honesto» antes de pasar a paper trading
  6. 2.6 · Métricas de rendimiento

    1. 2.6.1 CAGR, Sharpe, Sortino, Calmar, Omega, drawdown y Ulcer Index
    2. 2.6.2 Win rate, payoff, expectativa, alpha, beta, information ratio, turnover y capacidad
    3. 2.6.3 Informes con QuantStats o pyfolio-reloaded y tearsheets propios

Cada mirada a los datos es una prueba

El data snooping (fisgoneo de datos) ocurre cuando un mismo conjunto de datos se usa más de una vez para inferir o para elegir un modelo1Nota 1 · White (2000), en los recursos, define así el data snooping y propone un contraste (el reality check) de la hipótesis nula de que el mejor modelo de una búsqueda no supera a una referencia.. En trading es casi inevitable: solo hay una historia de precios y todo el mundo la usa. El mecanismo es sencillo y no exige ninguna mala fe:

  1. Se prueba una idea. El backtest sale regular.
  2. Se cambia un parámetro, un filtro, el universo o el periodo. Sale algo mejor.
  3. Se repite hasta que sale bien, y se presenta el último resultado.

Cada paso, por separado, es razonable. El problema está en el conjunto: el resultado final es el mejor de N intentos, y el mejor de N intentos tiene un sesgo hacia arriba aunque ninguno tenga ventaja. Un Sharpe de 1,0 que sale a la primera y uno de 1,0 que sale tras 200 intentos no significan lo mismo, y en el informe final no se distinguen salvo que alguien haya contado los intentos.

Este problema tiene herramientas estadísticas (corrección por pruebas múltiples, lección 2.1.8; Deflated Sharpe y PBO, lección 2.5.4; validación fuera de muestra, lecciones 2.5.2 y 2.5.3), pero todas necesitan un dato previo que solo existe si se ha anotado: cuántas pruebas se hicieron y cuáles. De eso trata esta lección.

El bucle de investigación

Un proceso de investigación disciplinado separa lo que se decide antes de mirar de lo que se mira después:

descartarajustarcandidataHipótesisDiseñoRegistro previoEjecuciónAnotar resultadoDecisiónValidar fuera
Fuente Mermaid
flowchart TB
  H[Hipótesis] --> D[Diseño]
  D --> R[Registro previo]
  R --> E[Ejecución]
  E --> A[Anotar resultado]
  A --> X{Decisión}
  X -- descartar --> H
  X -- ajustar --> D
  X -- candidata --> V[Validar fuera]

El punto clave es el registro previo: la hipótesis, los datos, el código y los parámetros se anotan antes de ver el resultado. Después se anota el resultado, salga como salga, y la decisión con su motivo. Cada vuelta del bucle (cada «ajustar») es una prueba más en la cuenta. Lo que se anota en cada experimento:

CampoQué guardaPor qué
HipótesisLa ventaja que se busca, en una frase (lección 2.2.1)Separa exploración de contraste; agrupa pruebas por familia
Huella del códigoHash del código que produce el resultado (en un repositorio, el commit de git)Saber qué se ejecutó exactamente
ParámetrosTodos, también los que «no importan»Reconstruir el resultado y contar combinaciones
Huella de los datosHash de la tabla de entrada, fechas y versión del ficheroDetectar que los datos cambiaron entre pruebas
MétricasLas que se decidieron antes (Sharpe, drawdown…), en la muestra de selecciónComparar lo comparable
Decisión y motivoDescartar, ajustar o pasar a validación, y por quéVer después qué se eligió mirando qué
Hash del anteriorHuella del registro previoQue ninguna línea se pueda editar o borrar sin que se note

Un registro mínimo en código

Las huellas convierten cosas grandes (una tabla de datos, una función) en una cadena corta que cambia si cambia cualquier detalle. SHA-256 de la biblioteca estándar (hashlib) basta; de los 64 caracteres hexadecimales se guardan 16:

IN [01]python
import hashlib
import inspect
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path

import numpy as np
import pandas as pd

def sha(texto):
    return hashlib.sha256(texto.encode()).hexdigest()

def huella_datos(obj):
    """Hash del contenido (valores e índice) de una tabla o array."""
    if isinstance(obj, np.ndarray):
        return sha(obj.tobytes().hex())[:16]
    h = pd.util.hash_pandas_object(obj, index=True).to_numpy()
    return sha(h.tobytes().hex())[:16]

def huella_codigo(fn):
    """Hash del código fuente de una función."""
    return sha(inspect.getsource(fn))[:16]

a = pd.DataFrame({"x": [1.0, 2.0, 3.0]})
b = a.copy()
b.loc[2, "x"] = 3.0000001
print(huella_datos(a), huella_datos(a.copy()), huella_datos(b))
OUT [01]Huellas de datos y de código
1be10aae2e9da811 1be10aae2e9da811 42a5a0bcda12d1b6

La misma tabla da la misma huella; un cambio en la séptima cifra decimal de un valor da otra completamente distinta. huella_codigo hace lo mismo con el texto de una función: cambiar un espacio cambia la huella. En un proyecto real, la huella del código es el commit de git (git rev-parse HEAD) más una marca de si había cambios sin confirmar (lección 0.2.5).

El registro es un fichero JSONL (una línea JSON por experimento) que solo se abre para añadir. Cada línea lleva el hash de la anterior, como una cadena: si alguien edita o borra una línea antigua, la cadena se rompe. El reloj se pasa como argumento: en producción sería datetime.now(timezone.utc); aquí es un reloj fijo que avanza un segundo por llamada, para que la salida de la lección sea siempre la misma.

IN [02]python
class Registro:
    def __init__(self, ruta, reloj):
        self.ruta, self.reloj = Path(ruta), reloj
        self.ruta.touch()

    def leer(self):
        with self.ruta.open(encoding="utf-8") as f:
            return [json.loads(linea) for linea in f if linea.strip()]

    def _ultimo(self):
        filas = self.leer()
        return filas[-1]["hash"] if filas else "0" * 16

    def anotar(self, **campos):
        reg = {"ts": self.reloj(), **campos, "previo": self._ultimo()}
        cuerpo = json.dumps(reg, sort_keys=True, ensure_ascii=False)
        reg["hash"] = sha(cuerpo)[:16]
        with self.ruta.open("a", encoding="utf-8") as f:
            f.write(json.dumps(reg, ensure_ascii=False) + "\n")
        return reg

    def verificar(self):
        """Índice del primer registro roto (None si todo cuadra)."""
        previo = "0" * 16
        for i, reg in enumerate(self.leer()):
            sin_hash = {k: v for k, v in reg.items() if k != "hash"}
            cuerpo = json.dumps(sin_hash, sort_keys=True,
                                ensure_ascii=False)
            roto = sha(cuerpo)[:16] != reg["hash"]
            if reg["previo"] != previo or roto:
                return i
            previo = reg["hash"]
        return None

INICIO = datetime(2026, 10, 21, 9, 0, tzinfo=timezone.utc)

def reloj_fijo(inicio=INICIO):
    t = [inicio]
    def ahora():
        t[0] += timedelta(seconds=1)
        return t[0].isoformat()
    return ahora

Antes de usarlo, un test: anotar tres experimentos, verificar la cadena, alterar un resultado antiguo y volver a verificar.

IN [03]python
ruta = Path("prueba_registro.jsonl")
ruta.unlink(missing_ok=True)
reg = Registro(ruta, reloj_fijo())
for k in range(3):
    reg.anotar(hipotesis="prueba", params={"k": k}, sharpe=0.1 * k)
print("cadena válida:", reg.verificar() is None)

lineas = ruta.read_text(encoding="utf-8").splitlines()
alterada = json.loads(lineas[1])
alterada["sharpe"] = 2.5                    # «mejorar» un resultado
lineas[1] = json.dumps(alterada, ensure_ascii=False)
ruta.write_text("\n".join(lineas) + "\n", encoding="utf-8")
print("primer registro roto:", reg.verificar())
print(lineas[0])
OUT [03]La cadena detecta una edición
cadena válida: True
primer registro roto: 1
{"ts": "2026-10-21T09:00:01+00:00", "hipotesis": "prueba", "params": {"k": 0}, "sharpe": 0.0, "previo": "0000000000000000", "hash": "b92a8426a21acdda"}

El registro 1 tiene ahora un contenido que no corresponde a su hash, y verificar lo señala. Si además se recalculara su hash, el registro 2 dejaría de coincidir con él. Para reescribir la historia sin que se note habría que recalcular toda la cadena desde el punto editado; publicar de vez en cuando el último hash (en un commit, en un mensaje con fecha) impide también eso.

Doscientas pruebas sobre ruido

Para ver el sesgo del mejor resultado hace falta un experimento en el que la respuesta se conozca: unos precios sin ninguna ventaja. Se generan diez años de rendimientos diarios normales e independientes, con media cero y un 1 % de volatilidad diaria. Sobre ellos, una búsqueda típica: cruce de medias móviles con 10 medias rápidas (de 5 a 50 sesiones), 10 lentas (de 60 a 240) y las dos direcciones (seguir el cruce o ir contra él), 200 combinaciones. La muestra de selección son las sesiones 260 a 1.259 (cuatro años); la de comprobación, las 1.260 siguientes (cinco).

IN [04]python
RAPIDAS = list(range(5, 55, 5))
LENTAS = list(range(60, 260, 20))
SELECCION, FUERA = slice(260, 1260), slice(1260, None)

def sharpe(x):
    return x.mean() / x.std() * np.sqrt(252)

def buscar(r):
    """Sharpe de selección y fuera de muestra de cada combinación."""
    p = np.exp(np.cumsum(r))
    cs = np.concatenate([[0.0], np.cumsum(p)])
    media = {w: np.r_[np.full(w - 1, np.nan), (cs[w:] - cs[:-w]) / w]
             for w in set(RAPIDAS) | set(LENTAS)}
    filas = []
    for f in RAPIDAS:
        for s in LENTAS:
            pos = np.sign(media[f] - media[s])
            pos = np.r_[np.nan, pos[:-1]]   # se opera al día siguiente
            for direccion in (1, -1):
                x = direccion * pos * r
                filas.append((f, s, direccion, sharpe(x[SELECCION]),
                              sharpe(x[FUERA])))
    return pd.DataFrame(filas, columns=["rapida", "lenta", "direccion",
                                        "sr_sel", "sr_fuera"])

rng = np.random.default_rng(2024)
ruido = rng.normal(0, 0.01, 2520)
prueba = buscar(ruido)
prueba[["sr_sel", "sr_fuera"]].describe().round(3)
OUT [04]La búsqueda: 200 combinaciones de medias móviles
        sr_sel  sr_fuera
count  200.000   200.000
mean     0.000     0.000
std      0.312     0.465
min     -0.846    -0.858
25%     -0.214    -0.427
50%      0.000     0.000
75%      0.214     0.427
max      0.846     0.858

Las 200 pruebas se anotan en el registro, una a una, con la huella del código de buscar, la de los datos y sus parámetros. Es lo que haría un investigador disciplinado con cada backtest:

IN [05]python
ruta = Path("experimentos.jsonl")
ruta.unlink(missing_ok=True)
registro = Registro(ruta, reloj_fijo())
codigo, datos = huella_codigo(buscar), huella_datos(ruido)
for fila in prueba.itertuples():
    registro.anotar(
        hipotesis="H1 cruce de medias en serie sin ventaja",
        codigo=codigo, datos=datos,
        params={"rapida": fila.rapida, "lenta": fila.lenta,
                "direccion": fila.direccion},
        sr_seleccion=round(fila.sr_sel, 4))

filas = registro.leer()
mejor = max(filas, key=lambda f: f["sr_seleccion"])
print(len(filas), "pruebas; cadena válida:",
      registro.verificar() is None)
print("mejor:", mejor["params"], mejor["sr_seleccion"])
fuera = prueba.loc[prueba["sr_sel"].idxmax(), "sr_fuera"]
print(f"la misma combinación fuera de muestra: {fuera:.3f}")
OUT [05]Registrar las 200 pruebas y elegir la mejor
200 pruebas; cadena válida: True
mejor: {'rapida': 20, 'lenta': 80, 'direccion': 1} 0.8458
la misma combinación fuera de muestra: 0.177

La descripción de la celda anterior tiene una simetría que no es casualidad: cada combinación aparece con las dos direcciones, así que los 200 Sharpe son 100 parejas de signo contrario, con media exactamente 0. La dispersión es de 0,31 entre combinaciones en la muestra de selección y de 0,47 fuera de ella.

La mejor combinación, rápida de 20, lenta de 80 y siguiendo el cruce, tiene un Sharpe de selección de 0,85: con cuatro años, eso es una t de 1,7, un resultado que en un informe parecería prometedor. En los cinco años siguientes la misma regla da 0,18. Sobre datos en los que, por construcción, ninguna regla tiene ventaja.

Cuánto sube el máximo con el número de pruebas

Una sola historia de ruido es una anécdota. Para medir el sesgo se repite la búsqueda en 50 historias independientes y, en cada una, se recorren las 200 pruebas en un orden aleatorio, como las haría un investigador, guardando el mejor Sharpe encontrado hasta cada intento:

IN [06]python
mejores, fuera_mejor, caminos = [], [], []
for _ in range(50):
    res = buscar(rng.normal(0, 0.01, 2520))
    orden = rng.permutation(len(res))
    sr = res["sr_sel"].to_numpy()[orden]
    caminos.append(np.maximum.accumulate(sr))
    i = res["sr_sel"].idxmax()
    mejores.append(res.loc[i, "sr_sel"])
    fuera_mejor.append(res.loc[i, "sr_fuera"])
medio = np.mean(caminos, axis=0)
print(f"mejor de 200 en selección: {np.mean(mejores):.2f} de media")
print(f"esa combinación fuera de muestra: {np.mean(fuera_mejor):.2f} "
      f"(desv. típica {np.std(fuera_mejor):.2f})")
pd.Series(medio[[0, 4, 9, 19, 49, 99, 199]],
          index=[1, 5, 10, 20, 50, 100, 200]).round(3)
OUT [06]50 historias de ruido: el mejor encontrado tras n pruebas
mejor de 200 en selección: 0.93 de media
esa combinación fuera de muestra: 0.02 (desv. típica 0.47)
1      0.014
5      0.477
10     0.584
20     0.709
50     0.826
100    0.884
200    0.930
dtype: float64

¿Cuánto debería subir? Si las N pruebas fueran independientes y sin ventaja, cada Sharpe estimado sería aproximadamente normal con media 0 y desviación típica σSR≈1/T\sigma_{SR} \approx 1/\sqrt{T}, con TT el número de años (con cuatro años de selección, 0,5). El máximo esperado de N normales tipificadas independientes es

E[max⁡i≤NZi]=∫−∞∞z N φ(z) Φ(z)N−1 dz≈2ln⁡NE\left[\max_{i \le N} Z_i\right] = \int_{-\infty}^{\infty} z \, N \, \varphi(z) \, \Phi(z)^{N-1} \, dz \approx \sqrt{2 \ln N}

con φ\varphi y Φ\Phi la densidad y la función de distribución normales; la raíz es la aproximación de valores extremos para N grande, que sobrestima con N pequeños. La integral se calcula numéricamente:

IN [07]python
from scipy import integrate, optimize, stats

def max_esperado(n):
    def f(z):
        return z * n * stats.norm.pdf(z) * stats.norm.cdf(z) ** (n - 1)
    return integrate.quad(f, -10, 10)[0]

sigma_sr = np.sqrt(252 / 1000)      # 1000 sesiones de selección
tabla = pd.DataFrame({
    "independientes": [max_esperado(n) * sigma_sr
                       for n in (1, 5, 10, 20, 50, 100, 200)],
    "raiz_2lnN": [np.sqrt(2 * np.log(n)) * sigma_sr
                  for n in (1, 5, 10, 20, 50, 100, 200)],
    "medias_moviles": medio[[0, 4, 9, 19, 49, 99, 199]],
}, index=[1, 5, 10, 20, 50, 100, 200])
n_efectivo = optimize.brentq(
    lambda n: max_esperado(n) * sigma_sr - np.mean(mejores), 1.5, 200)
print(f"número efectivo de pruebas: {n_efectivo:.0f} de 200")
tabla.round(3)
OUT [07]Máximo esperado de N pruebas independientes y número efectivo
número efectivo de pruebas: 19 de 200
     independientes  raiz_2lnN  medias_moviles
1             0.000      0.000           0.014
5             0.584      0.901           0.477
10            0.772      1.077           0.584
20            0.937      1.229           0.709
50            1.129      1.404           0.826
100           1.259      1.523           0.884
200           1.379      1.634           0.930

La figura de la cabecera dibuja las dos curvas. Tres lecturas:

  • El sesgo es grande y llega pronto. Con 10 pruebas el mejor Sharpe de selección ya es de 0,58 de media; con 200, de 0,93. Todo ello sobre datos en los que el Sharpe verdadero de cualquier regla es exactamente 0.
  • Fuera de muestra no queda nada. La combinación ganadora de cada historia tiene de media un Sharpe fuera de muestra de 0,02, con una desviación típica de 0,47: lo que da cualquier regla al azar en cinco años.
  • Las pruebas correladas cuentan menos que N. Las 200 combinaciones de medias se parecen mucho entre sí, y su máximo crece como el de 19 pruebas independientes (la integral da 0,94 para 20). Ese número efectivo es el que hay que dar a la corrección por pruebas múltiples, y estimarlo exige haber registrado todas las pruebas, también las que se parecían.

Con ventaja: la misma búsqueda en los futuros

El mismo procedimiento sobre datos con una ventaja plantada: la cartera de los 24 futuros del nivel, con el mismo riesgo por mercado, y las mismas 200 combinaciones. La selección es 2001-2012; la comprobación, 2013-2025. El momentum de series temporales plantado (lección 2.2.2) favorece seguir el cruce, no ir contra él:

IN [08]python
fut = pd.read_parquet("futuros2_continuos.parquet")
aj = fut.pivot(index="fecha", columns="mercado",
               values="cierre_ajustado")
r_f = aj.pct_change()
inv_vol = 1 / r_f.ewm(span=60, min_periods=60).std().shift(1)
medias = {w: aj.rolling(w).mean() for w in set(RAPIDAS) | set(LENTAS)}

def buscar_futuros():
    filas = []
    for f in RAPIDAS:
        for s in LENTAS:
            pos = np.sign(medias[f] - medias[s]).shift(1)
            x = (pos * inv_vol * r_f).mean(axis=1)
            for direccion in (1, -1):
                y = direccion * x
                filas.append((f, s, direccion,
                              sharpe(y.loc["2001":"2012"]),
                              sharpe(y.loc["2013":])))
    return pd.DataFrame(filas, columns=["rapida", "lenta", "direccion",
                                        "sr_sel", "sr_fuera"])

futr = buscar_futuros()
for fila in futr.itertuples():
    registro.anotar(
        hipotesis="H2 cruce de medias en 24 futuros",
        codigo=huella_codigo(buscar_futuros), datos=huella_datos(aj),
        params={"rapida": fila.rapida, "lenta": fila.lenta,
                "direccion": fila.direccion},
        sr_seleccion=round(fila.sr_sel, 4))
i = futr["sr_sel"].idxmax()
print("mejor:", futr.loc[i, ["rapida", "lenta", "direccion"]].tolist(),
      f"selección {futr.loc[i, 'sr_sel']:.2f}, "
      f"fuera {futr.loc[i, 'sr_fuera']:.2f}")
futr.groupby("direccion")[["sr_sel", "sr_fuera"]].mean().round(2)
OUT [08]200 combinaciones en los futuros, con ventaja plantada
mejor: [25, 160, 1] selección 0.97, fuera 0.83
           sr_sel  sr_fuera
direccion
-1          -0.75     -0.84
 1           0.75      0.84

Con ventaja, el patrón cambia. La mejor combinación (rápida de 25, lenta de 160, siguiendo el cruce) tiene un Sharpe de 0,97 en la selección y de 0,83 fuera: baja, pero sigue lejos de cero. La media de las 100 combinaciones que siguen el cruce va en sentido contrario: 0,75 en la selección y 0,84 fuera. En estos datos, 2013-2025 es mejor época para la tendencia que 2001-2012 (el README del nivel 2 da, para una cartera de momentum de los 24 mercados, un Sharpe de 0,75 en 2000-2009 y de 1,20 en 2010-2019), así que la familia sube fuera de muestra mientras la ganadora baja y acaba por debajo de la media. La lección práctica es doble: el mejor resultado está inflado también cuando hay ventaja, y la estimación de la familia (0,75, que se queda corta) se equivoca menos que la del máximo (0,97, que se pasa).

La figura compara las dos búsquedas por grupos: las 200 combinaciones ordenadas por su Sharpe de selección en diez grupos de 20, con el Sharpe medio fuera de muestra de cada grupo. Con ruido, los grupos oscilan entre −0,36 y +0,36 fuera de muestra sin ninguna dirección: el grupo con mejor selección da +0,07 y el segundo, −0,36. Haber salido mejor en la selección no dice nada del futuro. Con ventaja, la línea salta de −0,72 a +0,72 entre el quinto y el sexto grupo: la selección acierta la dirección, que es la ventaja plantada. Dentro de las combinaciones que siguen el cruce, el Sharpe fuera de muestra sube de 0,72 a 0,91 en los tres primeros grupos y después deja de subir: el grupo con mejor selección (0,88) da 0,87 fuera, menos que el tercero (0,91 con 0,75 de selección). La selección distingue algo dentro de la familia, pero en lo más alto solo ordena ruido: elegir la ganadora por su Sharpe de selección no añade nada a elegir entre las mejores de la familia.

Fig. — Las 200 combinaciones de cada búsqueda en diez grupos de 20 según su Sharpe de selección: Sharpe medio de selección y fuera de muestra de cada grupo, sobre ruido (izquierda) y sobre los futuros con momentum plantado (derecha)

Del registro a la corrección

El registro ya tiene 400 experimentos de dos hipótesis. Lo que da a las lecciones siguientes es el denominador:

IN [09]python
todo = pd.DataFrame(registro.leer())
resumen = todo.groupby("hipotesis").agg(
    pruebas=("hash", "size"),
    combinaciones=("params", lambda p: len(
        {json.dumps(x, sort_keys=True) for x in p})),
    datos=("datos", "nunique"),
    mejor_sr=("sr_seleccion", "max"))
print(resumen)
print("cadena válida:", registro.verificar() is None)

# p-valor de la mejor prueba de H1 sola y corregido por el número
# de pruebas (Bonferroni, lección 2.1.8), con N = 200 y con N efectivo
p_sola = stats.norm.sf(mejor["sr_seleccion"] / sigma_sr)
for n in (1, round(n_efectivo), 200):
    print(f"N = {n:>3}: p = {min(1, n * p_sola):.3f}")
OUT [09]Lo que el registro dice de la investigación
                                         pruebas  combinaciones  datos  mejor_sr
hipotesis
H1 cruce de medias en serie sin ventaja      200            200      1    0.8458
H2 cruce de medias en 24 futuros             200            200      1    0.9687
cadena válida: True
N =   1: p = 0.046
N =  19: p = 0.874
N = 200: p = 1.000

La tabla es el resumen que debería acompañar a cualquier resultado: cuántas pruebas, de cuántas combinaciones distintas y sobre cuántas versiones de los datos (una en cada hipótesis: si la huella hubiera cambiado entre pruebas, alguien habría tocado los datos a mitad de la búsqueda). La mejor prueba de H1, mirada sola, tiene un p-valor unilateral de 0,046: «significativa» al 5 %. Corregida por las 19 pruebas efectivas, 0,874; por las 200 nominales,

  1. La primera cifra es la que aparece en un informe sin registro. Las correcciones finas (FDR, Deflated Sharpe, PBO) son las de las lecciones 2.1.8 y 2.5.4; todas empiezan por este recuento.

Cada experimento pasa por unos pocos estados, y el registro guarda en qué estado acabó cada uno. Ninguno desaparece:

RegistradoEjecutadoDescartadoCandidatoValidadoPaper
Fuente Mermaid
stateDiagram-v2
  [*] --> Registrado
  Registrado --> Ejecutado
  Ejecutado --> Descartado
  Ejecutado --> Candidato
  Candidato --> Validado
  Candidato --> Descartado
  Validado --> Paper

Reglas de higiene que el registro hace posibles y que conviene escribir en el propio diario:

  • La hipótesis va antes que el backtest. Si una prueba no tiene hipótesis, se anota como exploración y su resultado no cuenta como evidencia.
  • Las métricas y el periodo de comprobación se fijan antes. Elegir después la métrica que mejor sale es otra forma de hacer pruebas múltiples.
  • La muestra de comprobación se usa una vez. Si un candidato falla fuera de muestra y se ajusta para que pase, la muestra de comprobación se ha convertido en muestra de selección (lección 2.5.2).
  • No se borra nada. Las pruebas que salen mal son el denominador.

Ejercicios

  1. Añade al registro un método ya_probado(codigo, datos, params) que diga si un experimento idéntico ya se ejecutó. ¿Por qué es útil y qué no detecta?
Solución
IN [10]python
def ya_probado(registro, codigo, datos, params):
    clave = json.dumps(params, sort_keys=True)
    return any(f.get("codigo") == codigo and f.get("datos") == datos
               and json.dumps(f.get("params"), sort_keys=True) == clave
               for f in registro.leer())

print(ya_probado(registro, codigo, datos,
                 {"rapida": 20, "lenta": 60, "direccion": 1}))
print(ya_probado(registro, codigo, datos,
                 {"rapida": 21, "lenta": 60, "direccion": 1}))
OUT [10]Detectar experimentos repetidos
True
False

Evita repetir un experimento sin saberlo y que se cuente dos veces o, peor, que se repita «a ver si ahora sale». No detecta las variaciones mínimas (una media de 21 en vez de 20 es otro experimento, aunque esté casi perfectamente correlado con el anterior): para eso está el número efectivo de pruebas, no la igualdad exacta.

  1. Repite la búsqueda sobre ruido con solo la dirección «seguir el cruce» (100 combinaciones). ¿Cómo cambian el mejor Sharpe de selección y el número efectivo? ¿Por qué?
Solución
IN [11]python
mejores_1 = []
for _ in range(50):
    res = buscar(rng.normal(0, 0.01, 2520))
    mejores_1.append(res.loc[res["direccion"] == 1, "sr_sel"].max())
n_ef_1 = optimize.brentq(
    lambda n: max_esperado(n) * sigma_sr - np.mean(mejores_1),
    1.01, 200)
print(f"mejor de 100: {np.mean(mejores_1):.2f}; "
      f"N efectivo {n_ef_1:.1f}")
OUT [11]Solo una dirección
mejor de 100: 0.59; N efectivo 5.2

Con una sola dirección, el mejor de 100 baja a 0,59 de media y el número efectivo, a unas 5 pruebas. La dirección contraria duplica el número de combinaciones, pero no son pruebas nuevas en el sentido de explorar señales distintas: son el espejo exacto de las otras. Lo que hace es convertir el máximo en el máximo del valor absoluto, y eso sube mucho el mejor resultado: de 0,59 a 0,93. Probar «también al revés» es una de las formas más baratas de inflar un backtest, y el registro la deja a la vista porque direccion es un parámetro más.

  1. Un compañero te enseña un backtest con Sharpe de 1,4 en cinco años y te dice que «solo probó tres cosas». ¿Qué le pedirías antes de creerlo?
Solución

El registro: qué tres cosas, con qué parámetros, en qué datos y en qué orden, y cuántas variantes de cada una (un «cruce de medias» puede ser cien combinaciones). Las huellas de los datos, para saber si las tres pruebas usaron la misma muestra o si el periodo cambió entre ellas (cambiar el periodo también es probar). La hipótesis escrita antes, para saber si el filtro o el universo se eligieron mirando el resultado. Y una muestra que no se haya usado en ninguna de las pruebas para comprobarlo: con cinco años, un Sharpe de 1,4 tiene un error típico de unos 0,45, así que incluso con tres pruebas reales la evidencia es moderada (lección 2.5.4).

Footnotes

  1. White (2000), en los recursos, define así el data snooping y propone un contraste (el reality check) de la hipótesis nula de que el mejor modelo de una búsqueda no supera a una referencia. ↩

Respuesta plegada en cada pregunta

Autoevaluación

  1. Un investigador prueba 200 combinaciones de parámetros sobre la misma historia y publica la mejor, con Sharpe de 1,0 en cuatro años. ¿Qué falta para valorarla?

    • ANada: un Sharpe de 1,0 en cuatro años es significativo
    • BSaber cuántas pruebas se hicieron y cómo de correladas eran: el mejor de 200 intentos sobre ruido ya da de media un Sharpe de 0,93 en una muestra así
    • CMás decimales en el Sharpe
    • DUn gráfico de la curva de capital
    Ver respuesta

    Respuesta B. Saber cuántas pruebas se hicieron y cómo de correladas eran: el mejor de 200 intentos sobre ruido ya da de media un Sharpe de 0,93 en una muestra así

    En la lección, sobre 50 historias de ruido sin ninguna ventaja, el mejor de 200 intentos tiene de media un Sharpe de 0,93 en la muestra de selección y de 0,02 fuera de ella. Sin el número de pruebas, el 1,0 publicado no distingue entre ventaja y selección.

  2. ¿Para qué sirve guardar en cada registro el hash del registro anterior?

    • APara que el fichero ocupe menos
    • BPara que cualquier edición o borrado de un registro antiguo rompa la cadena y se detecte al verificarla
    • CPara cifrar los resultados
    • DPara ordenar los registros por fecha
    Ver respuesta

    Respuesta B. Para que cualquier edición o borrado de un registro antiguo rompa la cadena y se detecte al verificarla

    Cada registro incluye el hash del anterior y su propio hash se calcula sobre todo su contenido. Cambiar un resultado antiguo cambia su hash, y el siguiente registro ya no coincide. Es la misma idea que los commits de git.

  3. El máximo esperado de 200 Sharpe independientes es de 1,38 con la muestra de la lección, pero el mejor de las 200 combinaciones de medias se queda en 0,93 de media. ¿Por qué?

    • APorque el ruido de la lección no es normal
    • BPorque las 200 pruebas están correladas (medias móviles parecidas dan señales parecidas): equivalen a unas pocas decenas de pruebas independientes
    • CPorque la fórmula del máximo está mal
    • DPorque hay una ventaja oculta
    Ver respuesta

    Respuesta B. Porque las 200 pruebas están correladas (medias móviles parecidas dan señales parecidas): equivalen a unas pocas decenas de pruebas independientes

    Pruebas correladas exploran menos posibilidades que pruebas independientes. El número efectivo de pruebas, el que da el mismo máximo esperado, sale de 19 en la lección. Contar 200 sería demasiado conservador; contar 1, mucho peor.

  4. En los futuros, con un momentum plantado, la mejor de 200 combinaciones tiene Sharpe de 0,97 en 2001-2012 y de 0,83 en 2013-2025. ¿Qué indica?

    • AQue la estrategia ha dejado de funcionar
    • BQue hay una ventaja real (fuera de muestra sigue positiva) y que el Sharpe de selección de la ganadora estaba inflado: fuera de muestra no supera a la media de todas las combinaciones con la dirección correcta (0,84)
    • CQue el registro tiene un error
    • DQue hay que elegir otra combinación
    Ver respuesta

    Respuesta B. Que hay una ventaja real (fuera de muestra sigue positiva) y que el Sharpe de selección de la ganadora estaba inflado: fuera de muestra no supera a la media de todas las combinaciones con la dirección correcta (0,84)

    La inflación existe también cuando hay ventaja: el mejor de muchos intentos recoge la ventaja más el mejor ruido. Fuera de muestra queda la ventaja. Por eso se registran todas las pruebas y se valora la familia, no solo la ganadora.

  5. ¿Qué no debe hacerse nunca con un registro de experimentos?

    • AAnotar las pruebas que salen mal
    • BBorrar las pruebas que salieron mal o repetir una prueba hasta que sale bien sin anotar los intentos
    • CGuardar la huella de los datos
    • DEscribir la hipótesis antes de ejecutar
    Ver respuesta

    Respuesta B. Borrar las pruebas que salieron mal o repetir una prueba hasta que sale bien sin anotar los intentos

    Las pruebas fallidas son las que dan el denominador. Un registro con solo los éxitos convierte una búsqueda de 200 intentos en una prueba única aparente, que es exactamente el sesgo que el registro debe evitar.

Ideas clave

  • Data snooping: usar la misma historia para elegir y para evaluar. El mejor de N backtests mide la ventaja más el mejor ruido de N intentos.
  • Un registro mínimo es un JSONL append-only: hipótesis, huella del código, parámetros, huella de los datos, métricas y decisión, con el hash del registro anterior para detectar ediciones.
  • Sobre ruido puro, el mejor de 200 combinaciones de medias da un Sharpe de 0,93 de media en la muestra de selección y de 0,02 fuera; el máximo de 200 pruebas independientes sería de 1,38.
  • Las pruebas correladas cuentan menos: las 200 de la lección equivalen a 19 independientes. Ese número efectivo es el que piden la corrección por pruebas múltiples (lección 2.1.8) y el Deflated Sharpe (lección 2.5.4).
  • Con ventaja real la inflación sigue existiendo, pero fuera de muestra queda algo: la familia de pruebas, no la ganadora, es la mejor estimación.

Recursos

Documentación

Datos