---
title: "2.2.4 Diario de investigación y registro de experimentos"
description: "Un registro de experimentos append-only con huellas de código, parámetros y datos, y 200 pruebas sobre ruido para ver cuánto infla el mejor resultado."
url: "https://pythonparatrading.com/curso-python-trading/nivel-2/diario-de-investigacion-y-registro-de-experimentos/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-2/diario-de-investigacion-y-registro-de-experimentos.png"
---

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

- Curso: [Nivel 2: Estrategias sistemáticas y backtesting riguroso](https://pythonparatrading.com/curso-python-trading/nivel-2/)
- Módulo: 2.2 · Anatomía de un sistema de trading
- Duración: 60 min
- Requisitos: [2.2.2 · Universo, señal, filtro, entrada, salida, sizing y gestión del riesgo](https://pythonparatrading.com/curso-python-trading/nivel-2/universo-senal-filtro-entrada-salida-y-sizing/), [0.4.5 · Logging profesional y testing con pytest](https://pythonparatrading.com/curso-python-trading/nivel-0/logging-y-testing-con-pytest/), [0.2.5 · Git y GitHub: commits, ramas, pull requests y .gitignore](https://pythonparatrading.com/curso-python-trading/nivel-0/git-y-github-para-trading/)

## Al terminar esta lección

- Explicar el data snooping: por qué el mejor de N backtests sobrevalora la ventaja aunque cada uno esté bien hecho.
- Programar un registro de experimentos append-only en JSONL con huellas (hash) del código, los parámetros y los datos, encadenadas para detectar ediciones.
- Medir sobre datos sin ventaja cómo crece el mejor Sharpe con el número de pruebas y compararlo con el máximo esperado de N normales independientes.
- Contar las pruebas reales y efectivas de una investigación para alimentar las correcciones por pruebas múltiples y el Deflated Sharpe.

## 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 modelo[^1]. 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:

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

| Campo | Qué guarda | Por qué |
|---|---|---|
| Hipótesis | La ventaja que se busca, en una frase (lección 2.2.1) | Separa exploración de contraste; agrupa pruebas por familia |
| Huella del código | Hash del código que produce el resultado (en un repositorio, el commit de git) | Saber qué se ejecutó exactamente |
| Parámetros | Todos, también los que «no importan» | Reconstruir el resultado y contar combinaciones |
| Huella de los datos | Hash de la tabla de entrada, fechas y versión del fichero | Detectar que los datos cambiaron entre pruebas |
| Métricas | Las que se decidieron antes (Sharpe, drawdown…), en la muestra de selección | Comparar lo comparable |
| Decisión y motivo | Descartar, ajustar o pasar a validación, y por qué | Ver después qué se eligió mirando qué |
| Hash del anterior | Huella del registro previo | Que 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:

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

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

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

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

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

```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)
```
```output
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 $\sigma_{SR} \approx 1/\sqrt{T}$, con $T$ 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\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:

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

```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)
```
```output
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).* Dos paneles de diez puntos. Sobre ruido, los grupos van de −0,54 a +0,54 en la selección y fuera de muestra oscilan entre −0,36 y +0,36 sin dirección: el mejor grupo da +0,07. Sobre los futuros, destacados, los cinco grupos que van contra el cruce dan entre −0,72 y −0,91 fuera y los cinco que lo siguen, entre +0,72 y +0,91: la selección acierta la dirección, y dentro de cada mitad el grupo con mejor selección (0,88) da 0,87 fuera, menos que el tercero (0,91).

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

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

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

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

Un registro no hace honesta una investigación: hace visible cuántas pruebas costó un resultado. Sigue siendo posible
probar fuera del registro. La disciplina consiste en que cada backtest que se mira pase por él, y en que el informe
final diga cuántas pruebas hubo detrás.

</aside>

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

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

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

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

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

[^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.

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

   - A. Nada: un Sharpe de 1,0 en cuatro años es significativo
   - 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í
   - C. Más decimales en el Sharpe
   - D. Un gráfico de la curva de capital

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

   - A. Para que el fichero ocupe menos
   - B. Para que cualquier edición o borrado de un registro antiguo rompa la cadena y se detecte al verificarla
   - C. Para cifrar los resultados
   - D. Para ordenar los registros por fecha

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

   - A. Porque el ruido de la lección no es normal
   - B. Porque las 200 pruebas están correladas (medias móviles parecidas dan señales parecidas): equivalen a unas pocas decenas de pruebas independientes
   - C. Porque la fórmula del máximo está mal
   - D. Porque hay una ventaja oculta

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

   - A. Que la estrategia ha dejado de funcionar
   - 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)
   - C. Que el registro tiene un error
   - D. Que hay que elegir otra combinación

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

   - A. Anotar las pruebas que salen mal
   - B. Borrar las pruebas que salieron mal o repetir una prueba hasta que sale bien sin anotar los intentos
   - C. Guardar la huella de los datos
   - D. Escribir la hipótesis antes de ejecutar

   *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

### Research

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

### Documentación

- [Python: hashlib](https://docs.python.org/3/library/hashlib.html) · docs.python.org
- [pandas: util.hash_pandas_object](https://pandas.pydata.org/docs/reference/api/pandas.util.hash_pandas_object.html) · pandas.pydata.org

### Libros

- [White (2000): A Reality Check for Data Snooping. Econometrica 68(5), 1097-1126](https://doi.org/10.1111/1468-0262.00152) · doi.org
- [Bailey, Borwein, López de Prado y Zhu (2014): Pseudo-Mathematics and Financial Charlatanism. Notices of the AMS 61(5)](https://doi.org/10.1090/noti1105) · doi.org
- [Bailey y López de Prado (2014): The Deflated Sharpe Ratio. Journal of Portfolio Management 40(5), 94-107](https://doi.org/10.3905/jpm.2014.40.5.094) · doi.org

### Datos

- [Datos del nivel 2: futuros continuos (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/futuros2_continuos.parquet) · Descarga · futuros2_continuos.parquet

Lección anterior: [2.2.3 · Frecuencias: intradía, swing y posicional, y la capacidad de una estrategia](https://pythonparatrading.com/curso-python-trading/nivel-2/frecuencias-y-capacidad-de-una-estrategia/)

Lección siguiente: [2.3.1 · Seguimiento de tendencia y momentum](https://pythonparatrading.com/curso-python-trading/nivel-2/seguimiento-de-tendencia-y-momentum/)
