Lección 6.4.6 · 75 min

Agentes de investigación: hipótesis, alpha mining y revisión

Un agente de investigación genera ideas, las programa y las prueba más deprisa que nadie. Eso multiplica el número de intentos, y con él la probabilidad de encontrar por azar una señal espectacular. Esta lección pone a un LLM y a un proponente simulado a minar 200 señales sobre las 120 acciones del nivel 6, mide cuánto da el azar, valida el proceso de selección y no solo al ganador, y revisa el código del agente hasta encontrar la anticipación que infla una señal real un 45 %.

Fig. — 200 señales de un proponente simulado sobre 120 acciones: t del IC semanal en descubrimiento frente a validación
Lecciones del nivel 6
  1. 6.1 · NLP financiero clásico y moderno

    1. 6.1.1 Tokenización, embeddings, diccionarios financieros y FinBERT
    2. 6.1.2 Fuentes de texto: EDGAR, CNMV, transcripciones y redes sociales
    3. 6.1.3 Event studies con señales de texto, alineación temporal y topic modeling
  2. 6.2 · LLMs para investigación y trading

    1. 6.2.1 Cómo funciona un LLM: del siguiente token a la API
    2. 6.2.2 Prompting estructurado: JSON que se puede validar
    3. 6.2.3 Guidance, KPIs y tono: qué extrae bien un LLM
    4. 6.2.4 Fine-tuning ligero para tareas financieras: LoRA y QLoRA
    5. 6.2.5 Sesgo de anticipación en LLMs: el modelo que ya conoce el futuro
  3. 6.3 · RAG financiero

    1. 6.3.1 Embeddings y bases vectoriales: FAISS, Chroma, pgvector y Qdrant
    2. 6.3.2 Chunking de documentos financieros: tablas, notas al pie y XBRL
    3. 6.3.3 RAG sobre informes, folletos y KID, y cómo evaluarlo
  4. 6.4 · Agentes de IA para trading

    1. 6.4.1 Agentes con herramientas: tool calling, ReAct y planificación
    2. 6.4.2 Marcos de agentes y MCP
    3. 6.4.3 Arquitecturas multiagente tipo TradingAgents
    4. 6.4.4 Evidencia y límites de los agentes
    5. 6.4.5 Guardarraíles para agentes: el agente propone, el motor decide
    6. 6.4.6 Agentes de investigación: hipótesis, alpha mining y revisión
  5. 6.5 · IA como copiloto del quant

    1. 6.5.1 Asistentes de código con un flujo de tests primero
    2. 6.5.2 Revisión automática de backtests y migraciones de código
    3. 6.5.3 Riesgos del código generado por IA
  6. 6.6 · Big data para trading

    1. 6.6.1 Cuándo los datos son «big»: ticks, libros, opciones y universos
    2. 6.6.2 Parquet, Arrow, Polars en streaming y DuckDB fuera de memoria
    3. 6.6.3 Dask, Spark y Ray: de una máquina a un clúster
    4. 6.6.4 Bases de series temporales: TimescaleDB, QuestDB, ClickHouse, ArcticDB y kdb+
    5. 6.6.5 Almacenamiento en la nube, lakehouse (Delta, Iceberg) y GPU (RAPIDS, cuDF)
  7. 6.7 · Datos alternativos

    1. 6.7.1 Panorama y tamaño del mercado de datos alternativos
    2. 6.7.2 Tipos de datos alternativos: qué mide cada uno
    3. 6.7.3 Web scraping responsable
    4. 6.7.4 Proveedores y due diligence de datos alternativos
    5. 6.7.5 Del dato alternativo a la señal
    6. 6.7.6 Satélite, visión por computador y cadena de suministro

Qué hace un agente de investigación

Un agente de investigación no opera: lee, propone hipótesis, escribe el código que las prueba, ejecuta el backtest y redacta lo que ha encontrado. Las lecciones 6.4.1 a 6.4.3 enseñan a construirlo con herramientas y varios pasos. Esta lección se ocupa de lo que pasa después, cuando funciona: un agente que prueba cien ideas en una tarde convierte la investigación en una minería de señales (alpha mining), y la minería tiene dos riesgos conocidos desde mucho antes de los LLM.

El primero es el de las pruebas múltiples (lecciones 2.1.8 y 2.5.4): con suficientes intentos, siempre aparece una señal espectacular por azar. El segundo es el código con anticipación (lecciones 0.2.6 y 5.2.2): el agente escribe código plausible que usa información que no existía en la fecha de la decisión. Los dos producen backtests mejores, que es justo lo que el agente intenta conseguir. El ciclo de investigación necesita, por tanto, dos puertas que el agente no puede abrir:

anticipaciónlimpiono pasapasaHipótesisCódigoRevisiónBacktestRegistroDSR y validaciónInforme
Fuente Mermaid
flowchart TB
  H[Hipótesis] --> C[Código]
  C --> R{Revisión}
  R -->|anticipación| C
  R -->|limpio| B[Backtest]
  B --> L[Registro]
  L --> D{DSR y validación}
  D -->|no pasa| H
  D -->|pasa| P[Informe]

La revisión del código va antes del backtest; el registro de todos los intentos (lección 2.2.4) y la corrección por selección van después, y solo valen si el registro está completo. La literatura de alpha mining con LLM es reciente (Alpha-GPT, de Wang et al., 2023; AlphaAgent, de Tang et al., 2025, que añade control de originalidad y de complejidad contra el sobreajuste y el decaimiento del alfa); las dos puertas valen para cualquiera de esos sistemas, porque no dependen de cómo se generan las ideas.

Los datos y la señal de noticias

El banco de pruebas son los precios de las 120 acciones y las 39.570 noticias del corpus del nivel 6, con el tono de diccionario de la lección 6.1.1: (positivas − negativas) / (positivas + negativas + 1) con el léxico financiero inventado de los datos. Las noticias de empresas que cambiaron de ticker llevan el ticker de su fecha; se traducen al actual con la historia de tickers.

La parte delicada es a qué sesión pertenece cada noticia. Una noticia publicada a las 18:00 de Nueva York no se puede usar para operar al cierre de ese día, que fue a las 16:00. El entorno ia no tiene exchange_calendars, así que la celda fija los cierres a mano: las 16:00 de Nueva York salvo en las 15 medias sesiones de 2019 a 2025, que cierran a las 13:00 (las fechas salen de exchange_calendars 4.13.2, calendario XNYS, en el entorno principal).

IN [01]python
import ast
import re
import warnings

import numpy as np
import pandas as pd
from scipy.stats import norm, rankdata

precios = pd.read_parquet("acciones6_precios.parquet")
C = precios.pivot(index="fecha", columns="ticker", values="cierre")
V = precios.pivot(index="fecha", columns="ticker",
                  values="volumen").astype(float)
R = C.pct_change()
sesiones = C.index

noticias = pd.read_parquet("noticias6_corpus.parquet")
historia = pd.read_csv("acciones6_tickers.csv")
empresas = pd.read_csv("acciones6_empresas.csv")
actual = empresas.set_index("id_empresa")["ticker"]
a_actual = dict(zip(historia["ticker"],
                    historia["id_empresa"].map(actual)))
noticias["tk"] = noticias["ticker"].map(a_actual)

# Cierre de cada sesión: 16:00 de Nueva York, salvo las 15 medias
# sesiones de 2019-2025 (13:00), según exchange_calendars 4.13.2.
MEDIAS = pd.to_datetime([
    "2019-07-03", "2019-11-29", "2019-12-24", "2020-11-27",
    "2020-12-24", "2021-11-26", "2022-11-25", "2023-07-03",
    "2023-11-24", "2024-07-03", "2024-11-29", "2024-12-24",
    "2025-07-03", "2025-11-28", "2025-12-24"])
hora = np.where(sesiones.isin(MEDIAS), 13, 16)
cierres = (sesiones + pd.to_timedelta(hora, unit="h")).tz_localize(
    "America/New_York").tz_convert("UTC")

lex = pd.read_csv("texto6_lexico.csv").query("lista == 'financiera'")
POS = set(lex.loc[lex["categoria"] == "positiva", "palabra"])
NEG = set(lex.loc[lex["categoria"] == "negativa", "palabra"])

def tono(texto):
    w = re.findall(r"[a-záéíóúüñ]+", texto.lower())
    p, n = sum(x in POS for x in w), sum(x in NEG for x in w)
    return (p - n) / (p + n + 1)

noticias["tono"] = [tono(t + " " + c) for t, c in
                    zip(noticias["titular"], noticias["cuerpo"])]
print(f"{C.shape[1]} acciones, {len(sesiones)} sesiones; "
      f"{len(noticias)} noticias, "
      f"{noticias['tk'].isna().sum()} sin empresa")
OUT [01]Precios, noticias y la sesión de cada noticia
120 acciones, 2011 sesiones; 39570 noticias, 0 sin empresa

Cada noticia cuenta en la primera sesión que cierra después de su publicación (searchsorted con side="right"): lo publicado tras el cierre, en fin de semana o en festivo pasa a la siguiente.

IN [02]python
def tono_por_sesion(noticias):
    """Cada noticia cuenta en la primera sesión que cierra DESPUÉS
    de publicarse: lo publicado tras el cierre, en la siguiente."""
    i = np.searchsorted(cierres.values, noticias["ts"].values,
                        side="right")
    ok = i < len(sesiones)
    n = noticias[ok].assign(sesion=sesiones[i[ok]])
    return (n.groupby(["sesion", "tk"])["tono"].sum().unstack()
            .reindex(index=sesiones, columns=C.columns).fillna(0.0))

T = tono_por_sesion(noticias)
i = np.searchsorted(cierres.values, noticias["ts"].values,
                    side="right")
fecha_ny = (noticias["ts"].dt.tz_convert("America/New_York")
            .dt.tz_localize(None).dt.normalize())
distinta = (sesiones[np.minimum(i, len(sesiones) - 1)]
            != fecha_ny.values).mean()
print(f"noticias cuya sesión no es su fecha de Nueva York: "
      f"{distinta:.0%}")
OUT [02]Tono por sesión, sin mirar al futuro
noticias cuya sesión no es su fecha de Nueva York: 33%

Un tercio de las noticias pertenece a una sesión distinta de su fecha de Nueva York. Es el dato que hará falta en la revisión del código del agente.

Un lenguaje cerrado para las señales

Dejar que un agente escriba código Python arbitrario para cada idea es la forma más rápida de multiplicar los errores. La alternativa habitual en alpha mining (las 101 alphas de Kakushadze, 2016, son fórmulas así) es un lenguaje de señales pequeño: unas pocas funciones de datos y unos pocos operadores, evaluados por un intérprete que sí está revisado. Aquí:

FunciónQué calcula (a cada cierre)
ret(n)Rentabilidad de las últimas n sesiones
vol(n)Desviación típica de los rendimientos diarios de n sesiones
volu(n)Log del volumen medio de n sesiones sobre el de 63
tono(n)Suma del tono de las noticias de las últimas n sesiones

Operadores: - (unario y binario), * y /. Cada función devuelve su rango normal transversal de cada semana (la transformación de la lección 5.2.4), así que las combinaciones se hacen entre puntuaciones comparables. El evaluador recorre el árbol sintáctico de Python y rechaza cualquier cosa que no sea del lenguaje: no hay eval, ni atributos, ni llamadas a otras funciones.

IN [03]python
semanas = pd.Series(sesiones, index=sesiones).groupby(
    sesiones.to_period("W")).max()
W = pd.DatetimeIndex(semanas.values)
W = W[(W >= "2019-01-04") & (W <= "2025-12-26")]
fin = C.reindex(semanas.values)        # cierre de cada semana
Y = (fin.shift(-1) / fin - 1).reindex(W).to_numpy()  # la siguiente

def g(df):
    """Rango normal transversal de cada semana."""
    x = df.reindex(W).to_numpy(float)
    n = np.isfinite(x).sum(axis=1, keepdims=True)
    return np.nan_to_num(norm.ppf(
        (rankdata(x, axis=1, nan_policy="omit") - 0.5) / n))

BASE = {
    "ret": lambda n: C / C.shift(n) - 1,
    "vol": lambda n: R.rolling(n).std(),
    "volu": lambda n: np.log(V.rolling(n).mean()
                             / V.rolling(63).mean()),
    "tono": lambda n: T.rolling(n).sum(),
}
_cache = {}

def evalua(expr):
    """Evalúa una señal del lenguaje; error si no es del lenguaje."""
    def nodo(x):
        if isinstance(x, ast.Expression):
            return nodo(x.body)
        if isinstance(x, ast.UnaryOp) and isinstance(x.op, ast.USub):
            return -nodo(x.operand)
        if isinstance(x, ast.BinOp) and isinstance(
                x.op, (ast.Sub, ast.Mult, ast.Div)):
            a, b = nodo(x.left), nodo(x.right)
            if isinstance(x.op, ast.Sub):
                return a - b
            if isinstance(x.op, ast.Mult):
                return a * b
            return a / np.where(np.abs(b) < 0.1, np.nan, b)
        if (isinstance(x, ast.Call) and isinstance(x.func, ast.Name)
                and x.func.id in BASE and len(x.args) == 1
                and isinstance(x.args[0], ast.Constant)
                and type(x.args[0].value) is int
                and 1 <= x.args[0].value <= 252 and not x.keywords):
            clave = (x.func.id, x.args[0].value)
            if clave not in _cache:
                _cache[clave] = g(BASE[clave[0]](clave[1]))
            return _cache[clave]
        raise ValueError(f"fuera del lenguaje: {ast.dump(x)[:40]}")
    return nodo(ast.parse(expr.strip(), mode="eval"))

def valida(linea):
    try:
        evalua(linea)
        return True
    except (SyntaxError, ValueError):
        return False

print(len(W), "semanas;", valida("-ret(5)"), valida("ret(5) + 1"),
      valida("**Momentum**: ret(252)"))
OUT [03]Un lenguaje pequeño para las señales
365 semanas; True False False

-ret(5) es válida; ret(5) + 1 no (no hay suma ni constantes sueltas), y una línea con texto alrededor tampoco: como en la lección 6.4.5, el evaluador valida, no repara.

El LLM como generador de hipótesis

La primera idea es pedirle hipótesis a un LLM. El de la lección es el modelo local pequeño del entorno ia (Qwen2.5-0.5B-Instruct, decodificación voraz). Primero, con la descripción del lenguaje y sin ejemplos:

IN [04]python
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

torch.manual_seed(0)
torch.use_deterministic_algorithms(True)
MODELO = "Qwen/Qwen2.5-0.5B-Instruct"
tok = AutoTokenizer.from_pretrained(MODELO)
llm = AutoModelForCausalLM.from_pretrained(MODELO,
                                           dtype=torch.float32)

def genera(mensajes, max_tokens):
    x = tok.apply_chat_template(mensajes, add_generation_prompt=True,
                                return_tensors="pt", return_dict=True)
    with torch.inference_mode():
        y = llm.generate(**x, do_sample=False,
                         max_new_tokens=max_tokens,
                         pad_token_id=tok.eos_token_id)
    n = x["input_ids"].shape[1]
    return tok.decode(y[0, n:], skip_special_tokens=True)

INSTRUCCIONES = (
    "Propones señales para ordenar acciones. Usa SOLO este lenguaje: "
    "ret(n) rentabilidad de n sesiones; vol(n) volatilidad de n "
    "sesiones; volu(n) volumen medio de n sesiones; tono(n) tono "
    "medio de las noticias de n sesiones; operadores - * / y "
    "numeros. Escribe 5 señales, una por linea, sin explicaciones.")
texto = genera([{"role": "system", "content": INSTRUCCIONES},
                {"role": "user", "content":
                 "Hipotesis para un horizonte de una semana."}], 80)
lineas = [l for l in texto.splitlines() if l.strip()]
for l in lineas:
    print(l[:86] + ("…" if len(l) > 86 else ""))
print(f"válidas: {sum(map(valida, lineas))} de {len(lineas)}")
OUT [04]El LLM como generador de hipótesis
1. **Horizonte de la semana**: "El precio del producto aumentó en un 20% en el primer …
2. **Horizonte de la semana**: "La volatilidad del mercado subió a un nivel de 3% en l…
3. **Horizonte de la semana**: "El volumen medio de
válidas: 0 de 3

Ninguna línea es del lenguaje: el modelo escribe frases sobre «el horizonte de la semana» con cifras inventadas, y las 80 fichas se acaban antes de la quinta. Con un modelo pequeño, una instrucción de formato sin ejemplos sirve de poco. Con cinco ejemplos en la conversación (few-shot, lección 6.2.2) el formato mejora:

IN [05]python
EJEMPLOS = "\n".join(["-ret(5)", "ret(252)-ret(21)", "tono(5)",
                      "-vol(63)", "ret(5)*volu(5)"])
texto = genera([
    {"role": "system", "content":
     "Escribe señales en este lenguaje: ret(n), vol(n), volu(n), "
     "tono(n), con - * /. Solo expresiones, una por linea."},
    {"role": "user", "content": "5 señales"},
    {"role": "assistant", "content": EJEMPLOS},
    {"role": "user", "content": "5 señales distintas"}], 60)
lineas = [l.strip() for l in texto.splitlines() if l.strip()]
unicas = list(dict.fromkeys(l for l in lineas if valida(l)))
copias = [l for l in unicas if l in EJEMPLOS.split("\n")]
print(lineas)
print(f"válidas {sum(map(valida, lineas))}, distintas {len(unicas)},"
      f" copiadas de los ejemplos {len(copias)}: {copias}")
OUT [05]Con ejemplos: formato correcto, poca variedad
['-ret(5)', '-tono(5)', '-ret(5)', '-vol(63)', '-volu(5)']
válidas 5, distintas 4, copiadas de los ejemplos 2: ['-ret(5)', '-vol(63)']

Las cinco líneas son del lenguaje, pero solo hay cuatro distintas: dos son copias de los ejemplos (-ret(5), que además sale dos veces, y -vol(63)) y las otras dos son piezas de los ejemplos con un signo menos delante. Es un rasgo general de la generación con ejemplos, no solo del modelo pequeño: las propuestas se anclan en lo que se les muestra. Un modelo grande propone ideas más variadas y mejor razonadas, pero el lenguaje cerrado, la validación y la eliminación de duplicados siguen haciendo falta. Y la calidad de las ideas se mide después, con los datos, igual que las de cualquier otro proponente.

Un proponente simulado: 200 señales

Para estudiar lo que pasa cuando un agente prueba muchas ideas no hace falta que las ideas sean buenas: hace falta que sean muchas. El proponente simulado enumera todas las señales de una o dos funciones (450 con 15 funciones base) y elige 200 al azar, con semilla. Un agente real propone ideas más razonables, pero también más parecidas entre sí; lo que importa para las pruebas múltiples es el número de intentos y su correlación.

IN [06]python
FUNCIONES = ([f"ret({n})" for n in (1, 5, 10, 21, 63, 126, 252)]
             + [f"vol({n})" for n in (5, 21, 63, 126)]
             + ["volu(5)", "volu(21)", "tono(5)", "tono(21)"])
espacio = [f for f in FUNCIONES] + [f"-{f}" for f in FUNCIONES]
for i, a in enumerate(FUNCIONES):
    for b in FUNCIONES[i + 1:]:
        espacio += [f"{a}*{b}", f"-{a}*{b}", f"{a}-{b}", f"{b}-{a}"]
rng = np.random.default_rng(646)
candidatas = [str(c) for c in
              rng.choice(espacio, size=200, replace=False)]
print(f"{len(espacio)} señales posibles; {len(candidatas)} elegidas")
print(candidatas[:6])
OUT [06]Un proponente simulado: 200 señales
450 señales posibles; 200 elegidas
['volu(5)*volu(21)', '-ret(10)*tono(5)', 'vol(5)-vol(21)', '-tono(21)', '-ret(1)*vol(5)', 'tono(21)-volu(5)']

Cada señal se mide con su IC semanal: la correlación de rangos entre la señal al cierre del último día de cada semana y la rentabilidad hasta el cierre de la semana siguiente, en las 120 acciones. El periodo se parte en dos: el descubrimiento (de 2019 a junio de 2022), donde el agente elige, y la validación (de julio de 2022 a 2025), que no se mira hasta el final. Entre los dos se deja una semana de embargo: la etiqueta de la última semana de descubrimiento termina el día en que empieza la validación. Con etiquetas de una semana sin solapamiento basta una; con etiquetas de 21 sesiones habría que purgar cuatro (lección 5.4.2).

IN [07]python
Yr = rankdata(Y, axis=1)
Yc = Yr - Yr.mean(axis=1, keepdims=True)

def ic_semanal(score):
    s = rankdata(np.nan_to_num(score, nan=0.0), axis=1)
    s = s - s.mean(axis=1, keepdims=True)
    den = np.sqrt((s * s).sum(1) * (Yc * Yc).sum(1))
    with np.errstate(invalid="ignore", divide="ignore"):
        return (s * Yc).sum(1) / den

def t_ic(x):
    x = x[np.isfinite(x)]
    return x.mean() / x.std(ddof=1) * np.sqrt(len(x))

IC = pd.DataFrame({c: ic_semanal(evalua(c)) for c in candidatas},
                  index=W)
corte = pd.Timestamp("2022-06-30")
descubre = IC.index < corte
embargo = W[descubre][-1:]   # su etiqueta acaba en la 1.ª validación
descubre &= ~IC.index.isin(embargo)
valida_ = IC.index > corte
res = pd.DataFrame({
    "IC desc.": IC[descubre].mean(), "t desc.": IC[descubre].apply(
        lambda x: t_ic(x.to_numpy())),
    "IC valid.": IC[valida_].mean(), "t valid.": IC[valida_].apply(
        lambda x: t_ic(x.to_numpy()))})
print(f"descubrimiento: {descubre.sum()} semanas hasta "
      f"{W[descubre][-1].date()}; validación: {valida_.sum()} desde "
      f"{W[valida_][0].date()}")
res.sort_values("t desc.", ascending=False).head(10).round(3)
OUT [07]IC semanal de las 200, con embargo entre periodos
descubrimiento: 181 semanas hasta 2022-06-17; validación: 183 desde 2022-07-01
                   IC desc.  t desc.  IC valid.  t valid.
-ret(21)*vol(21)      0.024    3.285      0.001     0.101
-ret(63)*ret(252)     0.017    2.542     -0.004    -0.532
-ret(10)*vol(21)      0.017    2.424      0.014     1.886
tono(5)               0.015    2.331      0.013     1.890
ret(126)-ret(252)     0.016    1.989     -0.010    -1.165
-ret(126)*vol(5)      0.014    1.881     -0.001    -0.076
ret(63)-ret(21)       0.016    1.835     -0.008    -0.837
tono(5)-ret(21)       0.016    1.796      0.001     0.101
ret(63)-volu(21)      0.013    1.738     -0.003    -0.410
-ret(10)*tono(5)      0.012    1.596     -0.004    -0.525

La ganadora del descubrimiento, -ret(21)*vol(21) (reversión de un mes en las acciones volátiles), tiene t = 3,29. En validación, t = 0,10. La segunda, la tercera y casi todas las demás del top 10 caen igual. Solo dos se sostienen algo: tono(5), la señal de noticias con efecto plantado, que pasa de 2,33 a 1,89, y -ret(10)*vol(21), de 2,42 a 1,89, pariente de la reversión del panel del nivel 5. La figura de la cabecera pone las 200 en el plano: la nube no tiene ninguna inclinación (la correlación entre el t de descubrimiento y el de validación es −0,08), y el ganador está en la esquina donde se espera que esté el máximo de una nube de ruido.

Las cuatro propuestas del LLM, medidas igual:

IN [08]python
pd.DataFrame({c: {"t desc.": t_ic(ic_semanal(evalua(c))[descubre]),
                  "t valid.": t_ic(ic_semanal(evalua(c))[valida_])}
              for c in unicas}).T.round(2)
OUT [08]Las propuestas del LLM, medidas
          t desc.  t valid.
-ret(5)     -0.25      1.11
-tono(5)    -2.33     -1.89
-vol(63)    -0.34     -0.48
-volu(5)     0.41      1.42

-tono(5) tiene t = −2,33 y −1,89: el modelo propuso la señal de noticias con el signo contrario al efecto real, porque copió el ejemplo tono(5) con el patrón de signo menos de los demás. Las otras tres no se distinguen de cero.

Cuánto da el azar entre 200 intentos

Tres maneras de preguntar lo mismo: ¿la ganadora es mejor de lo que daría el azar con tantos intentos?

IN [09]python
from scipy.stats import t as t_dist

m, n_d = len(res), int(descubre.sum())
p = 2 * t_dist.sf(res["t desc."].abs(), n_d - 1)
bh = np.flatnonzero(np.sort(p) <= 0.10 * np.arange(1, m + 1) / m)
print(f"|t| > 1,96: {(res['t desc.'].abs() > 1.96).sum()} de {m}"
      f" (por azar se esperarían {0.05 * m:.0f})")
print(f"Bonferroni al 5 %: {(p < 0.05 / m).sum()}; "
      f"Benjamini-Hochberg al 10 %: {bh.max() + 1 if len(bh) else 0}")

def max_esperado(n):
    g = np.euler_gamma
    return ((1 - g) * norm.ppf(1 - 1 / n)
            + g * norm.ppf(1 - 1 / (n * np.e)))

autoval = np.linalg.eigvalsh(IC[descubre].fillna(0).corr())
n_ef = autoval.sum() ** 2 / (autoval ** 2).sum()
print(f"intentos efectivos: {n_ef:.0f}; t máximo esperado sin "
      f"ninguna señal: {max_esperado(m):.2f} (N = {m}), "
      f"{max_esperado(n_ef):.2f} (N = {n_ef:.0f})")

verdad = pd.read_parquet("panel5_verdad.parquet").pivot(
    index="fecha", columns="ticker", values="alfa_verdadero")
ic_or = pd.Series(ic_semanal(
    verdad.reindex(index=W, columns=C.columns).to_numpy()), index=W)
print(f"oráculo del nivel 5: IC {ic_or[descubre].mean():.3f} "
      f"(t {t_ic(ic_or[descubre].to_numpy()):.2f}) en descubrimiento,"
      f" {ic_or[valida_].mean():.3f} en validación")
OUT [09]¿Cuánto da el azar entre 200 intentos?
|t| > 1,96: 10 de 200 (por azar se esperarían 10)
Bonferroni al 5 %: 0; Benjamini-Hochberg al 10 %: 0
intentos efectivos: 16; t máximo esperado sin ninguna señal: 2.77 (N = 200), 1.81 (N = 16)
oráculo del nivel 5: IC 0.021 (t 2.36) en descubrimiento, 0.029 en validación
  • Recuento: 10 de 200 señales tienen |t| > 1,96, exactamente las que se esperan por azar al 5 %.
  • Correcciones (lección 2.1.8): ni Bonferroni ni Benjamini-Hochberg al 10 % dejan pasar ninguna.
  • Máximo esperado: sin ninguna señal, el mejor t de 200 intentos independientes rondaría 2,77. Las 200 están correlacionadas (comparten funciones), y la razón de participación de los autovalores de su correlación da unos 16 intentos efectivos, con un máximo esperado de 1,81. Con la cuenta más favorable, el 3,29 de la ganadora queda por encima; con la honesta, poco por encima.

La última línea es la más incómoda. El alfa verdadero del panel del nivel 5, el oráculo que conoce el efecto plantado, tiene en el descubrimiento un IC de 0,021 y un t de 2,36. La ganadora tiene un IC de 0,024, mayor que el del oráculo: es imposible salvo por suerte. Y una señal real con t = 2,36 queda por debajo del máximo que da el ruido con 200 intentos. Minar mucho no solo genera falsos positivos: también entierra las señales verdaderas entre ellos.

Fig. — El t más alto que se espera encontrar sin ninguna señal según cuántas se prueben, frente a la ganadora de las 200 y al alfa verdadero

La figura pone las tres cosas juntas: la curva del máximo esperado por azar según el número de intentos, el t de la ganadora y la línea del alfa verdadero. A partir de unos 60 intentos, el ruido supera de media a la señal verdadera.

La ganadora como cartera: Deflated Sharpe

El IC no es dinero. La cartera natural es larga en el quintil alto de la señal y corta en el bajo, equiponderada y rebalanceada cada semana (sin costes: aquí solo interesa la selección; con costes, lección 5.4.4). Su Sharpe se corrige con el Deflated Sharpe de la lección 2.5.4, con la dispersión de los Sharpes de las 200 carteras:

IN [10]python
def larga_corta(score, q=0.2):
    """Quintil alto menos quintil bajo, equiponderado, semanal."""
    rk = rankdata(np.nan_to_num(score), axis=1) / score.shape[1]
    n = score.shape[1]
    w = ((rk > 1 - q).astype(float) - (rk <= q)) / (q * n)
    return pd.Series((w * np.nan_to_num(Y)).sum(1), index=W)

def psr(sr, umbral, x):
    asim, curt = x.skew(), x.kurt() + 3
    den = np.sqrt(1 - asim * sr + (curt - 1) / 4 * sr ** 2)
    return float(norm.cdf((sr - umbral) * np.sqrt(len(x) - 1) / den))

carteras = pd.DataFrame({c: larga_corta(evalua(c))
                         for c in candidatas})
sr = carteras[descubre].mean() / carteras[descubre].std()
mejor = res["t desc."].idxmax()
x = carteras.loc[descubre, mejor]
for n in (1, n_ef, m):
    sr0 = np.sqrt(sr.var()) * max_esperado(n) if n > 1 else 0.0
    print(f"N = {n:5.0f}: umbral {sr0 * np.sqrt(52):.2f} anual,"
          f" DSR = {psr(sr[mejor], sr0, x):.3f}")
for nombre, z in (("descubrimiento", x),
                  ("validación", carteras.loc[valida_, mejor])):
    print(f"{mejor}, {nombre}: Sharpe anual "
          f"{z.mean() / z.std() * np.sqrt(52):.2f}")
OUT [10]El ganador como cartera: Sharpe y Deflated Sharpe
N =     1: umbral 0.00 anual, DSR = 0.998
N =    16: umbral 0.97 anual, DSR = 0.841
N =   200: umbral 1.48 anual, DSR = 0.514
-ret(21)*vol(21), descubrimiento: Sharpe anual 1.50
-ret(21)*vol(21), validación: Sharpe anual -0.06

Como único intento (PSR con umbral cero), el Sharpe anual de 1,50 en tres años y medio parece concluyente: 0,998. Con los 16 intentos efectivos, el umbral sube a un Sharpe de 0,97 y el DSR baja a 0,84; con los 200 declarados, el umbral es 1,48, casi el Sharpe de la ganadora, y el DSR, 0,51. En validación, el Sharpe es −0,06. El DSR no sabía lo que iba a pasar en validación: dijo que la ganadora no se distinguía del mejor de 200 intentos sin ventaja, y era cierto.

Validar el proceso, no la ganadora

La validación de la sección anterior tiene un problema práctico: solo se puede hacer una vez. Si el agente mira la validación, elige otra señal y vuelve a mirar, la validación se convierte en descubrimiento. Y, sobre todo, lo que se va a usar en el futuro no es la ganadora de hoy, sino el proceso: el agente seguirá probando 200 ideas y quedándose con la mejor.

Así que lo que hay que validar es el proceso. La celda parte las 365 semanas en seis bloques y, para cada uno, elige la mejor señal con los otros cinco (dejando una semana de embargo a cada lado del bloque de prueba, como en el purged k-fold de la lección 5.4.2) y mide su IC en el bloque que quedó fuera:

IN [11]python
bloques = np.array_split(np.arange(len(W)), 6)
filas = []
for k, prueba in enumerate(bloques):
    entrena = np.ones(len(W), bool)
    entrena[max(prueba[0] - 1, 0):prueba[-1] + 2] = False  # embargo
    t_ent = IC[entrena].apply(lambda x: t_ic(x.to_numpy()))
    elegida = t_ent.idxmax()
    filas.append({"bloque": f"{W[prueba[0]]:%Y-%m} a "
                            f"{W[prueba[-1]]:%Y-%m}",
                  "elegida": elegida,
                  "t entrena": round(t_ent.max(), 2),
                  "IC prueba": round(IC.iloc[prueba][elegida].mean(),
                                     4),
                  "IC tono(5)": round(IC.iloc[prueba]["tono(5)"].mean(),
                                      4)})
cv = pd.DataFrame(filas).set_index("bloque")
print(cv)
print(f"media: elegida {cv['IC prueba'].mean():.4f}; "
      f"tono(5) {cv['IC tono(5)'].mean():.4f}")
OUT [11]Validar el proceso, no el ganador
                            elegida  t entrena  IC prueba  IC tono(5)
bloque
2019-01 a 2020-02           tono(5)       2.86     0.0111      0.0111
2020-03 a 2021-04  -ret(10)*vol(21)       3.41     0.0002      0.0171
2021-05 a 2022-07  -ret(10)*vol(63)       3.03     0.0024      0.0174
2022-07 a 2023-09  -ret(10)*vol(21)       3.20     0.0063      0.0106
2023-09 a 2024-11  -ret(10)*vol(21)       3.19     0.0028      0.0295
2024-11 a 2025-12           tono(5)       3.29     0.0021      0.0021
media: elegida 0.0042; tono(5) 0.0146
Fig. — Validación cruzada del proceso de selección en seis bloques con embargo: IC de prueba de la señal elegida en cada vuelta frente a tono(5)

El proceso de elegir la mejor de 200 da un IC medio de 0,0042 fuera de muestra. La señal de noticias, fija de antemano, 0,0146: más del triple. Cuando el proceso elige tono(5) (dos de los seis bloques) obtiene lo mismo que ella; en los otros cuatro elige variantes de reversión por volatilidad con t de entrenamiento por encima de 3 que, fuera del bloque, dan entre 0,0002 y 0,0063.

La conclusión de la sección: una hipótesis con un mecanismo (las noticias se incorporan al precio con retraso, lección 6.1.3), declarada antes de mirar los datos, vale más que la mejor de 200 combinaciones sin mecanismo, aunque la segunda tenga mejor t en la muestra. Un agente de investigación útil propone hipótesis con mecanismo y las declara antes de probarlas; uno que mina sin más produce, sobre todo, ganadoras de la muestra.

La revisión del código del agente

Las pruebas múltiples son un problema de cuántas ideas se prueban. La anticipación es un problema de cómo se programa cada una, y un lenguaje cerrado no lo evita del todo: alguien tiene que programar las funciones base, y el agente también propone funciones nuevas. Dos propuestas, típicas, para la señal de noticias:

IN [12]python
propuesta_a = """
def tono_suave(T):
    # suaviza el tono para quitar ruido
    return T.rolling(5, center=True).sum()
"""
propuesta_b = """
def tono_diario(noticias):
    # tono de cada día de Nueva York, por empresa
    fecha = (noticias["ts"].dt.tz_convert("America/New_York")
             .dt.tz_localize(None).dt.normalize())
    return (noticias.groupby([fecha, "tk"])["tono"].sum().unstack()
            .reindex(index=sesiones, columns=C.columns).fillna(0.0))
"""

def revision_estatica(codigo):
    """Patrones de anticipación que se ven en el texto del código."""
    avisos = []
    for n in ast.walk(ast.parse(codigo)):
        if isinstance(n, ast.keyword) and n.arg == "center":
            avisos.append(f"línea {n.value.lineno}: center=")
        if (isinstance(n, ast.Call)
                and isinstance(n.func, ast.Attribute)
                and n.func.attr == "shift" and n.args
                and isinstance(n.args[0], ast.UnaryOp)):
            avisos.append(f"línea {n.lineno}: shift negativo")
        if isinstance(n, ast.Attribute) and n.attr in ("bfill",
                                                       "backfill"):
            avisos.append(f"línea {n.lineno}: {n.attr}")
    return avisos or ["sin avisos"]

for nombre, codigo in (("A", propuesta_a), ("B", propuesta_b)):
    print(nombre, revision_estatica(codigo))
OUT [12]Dos propuestas de código del agente
A ['línea 4: center=']
B ['sin avisos']

La revisión estática recorre el árbol sintáctico buscando patrones conocidos de anticipación: center= en una ventana móvil, shift con un argumento negativo, bfill. Encuentra la ventana centrada de la propuesta A, que suma el tono de las dos sesiones siguientes. La propuesta B pasa limpia: agrupar por fecha no tiene nada de sospechoso en el texto.

La propuesta B tiene el error que el README del nivel 6 documenta: asigna cada noticia a su fecha de Nueva York, así que una noticia publicada el viernes a las 18:00 cuenta en la señal del viernes, calculada al cierre de las 16:00. Para verla hace falta una prueba point-in-time: lo que la función calcula para la fecha D con todas las noticias tiene que coincidir con lo que calcula con las publicadas antes del cierre de D. Es el test de truncado de la lección 0.2.6, pero cortando por el instante de publicación, no por la fecha:

IN [13]python
espacio_agente = {"np": np, "pd": pd, "sesiones": sesiones, "C": C}
exec(propuesta_b, espacio_agente)
tono_diario = espacio_agente["tono_diario"]

def prueba_pit(funcion, fechas):
    """Lo calculado para la fecha D con todas las noticias tiene que
    coincidir con lo calculado con las publicadas antes del cierre
    de D. Devuelve las fechas y las celdas que cambian."""
    completo = funcion(noticias)
    malas, celdas = 0, 0
    for d in fechas:
        corte = cierres[sesiones.get_loc(d)]
        parcial = funcion(noticias[noticias["ts"] < corte])
        dif = ~np.isclose(completo.loc[d], parcial.loc[d])
        malas, celdas = malas + dif.any(), celdas + dif.sum()
    return malas, celdas

muestra = W[::18]                       # 21 viernes repartidos
for nombre, f in (("tono_por_sesion", tono_por_sesion),
                  ("tono_diario (B)", tono_diario)):
    malas, celdas = prueba_pit(f, muestra)
    print(f"{nombre:<16} fechas con diferencias: {malas} de "
          f"{len(muestra)}; celdas: {celdas}")
OUT [13]La prueba point-in-time
tono_por_sesion  fechas con diferencias: 0 de 21; celdas: 0
tono_diario (B)  fechas con diferencias: 20 de 21; celdas: 49

La función correcta da lo mismo en las 21 fechas de la muestra. La propuesta B cambia en 20 de 21: 49 valores de la señal del viernes dependen de noticias publicadas después del cierre. Lo que cuesta no verlo:

IN [14]python
for nombre, T_ in (("correcta", T),
                   ("propuesta B", tono_diario(noticias))):
    ic = ic_semanal(g(T_.rolling(5).sum()))
    print(f"tono(5) {nombre:<12} IC {np.nanmean(ic):.4f}  "
          f"t {t_ic(ic):.2f}")
OUT [14]Cuánto infla la anticipación
tono(5) correcta     IC 0.0147  t 3.08
tono(5) propuesta B  IC 0.0214  t 4.39

Con la alineación de la propuesta B, el IC de tono(5) sube de 0,0147 a 0,0214 y su t de 3,08 a 4,39: un 45 % más. Es el caso más peligroso de anticipación. No convierte ruido en señal (eso se detecta por inverosímil): exagera una señal real, y el resultado sigue pareciendo razonable. Las noticias del viernes por la tarde mueven el precio el lunes, que está dentro de la semana de la etiqueta.

Capa de revisiónQué detectaQué no detecta
Lenguaje cerradoCódigo arbitrario, llamadas a datos no permitidosErrores en las funciones base
Revisión estáticacenter=True, shift(-k), bfillAlineaciones por fecha, uniones con datos futuros
Prueba point-in-timeCualquier dependencia de datos posteriores al corteDatos que ya vienen con anticipación del proveedor
Registro y DSRLa selección entre muchos intentosUna señal con anticipación (lección 5.4.4)
Validación del procesoGanadoras que no se sostienenUn efecto que cambia en el futuro

La lección 6.5.2 automatiza la revisión de backtests completos; la 6.5.3 trata los demás riesgos del código generado. La lista de comprobación de la lección 2.5.8 sigue valiendo para todo lo que el agente entrega.

Ejercicios

  1. Repite la minería con el doble de señales (400 de las 450) y la misma semilla. ¿Cuánto sube el máximo esperado por azar? ¿Qué t tiene la ganadora en descubrimiento y en validación, y pasa Bonferroni?
Solución
IN [15]python
rng4 = np.random.default_rng(646)
cand4 = [str(c) for c in rng4.choice(espacio, size=400, replace=False)]
IC4 = pd.DataFrame({c: ic_semanal(evalua(c)) for c in cand4}, index=W)
t4 = IC4[descubre].apply(lambda x: t_ic(x.to_numpy()))
g4 = t4.idxmax()
print(f"máximo esperado por azar (N = 400): {max_esperado(400):.2f}")
p4 = 2 * t_dist.sf(t4.max(), n_d - 1) * 400
print(f"ganadora {g4}: IC {IC4.loc[descubre, g4].mean():.3f}, "
      f"t {t4.max():.2f} (p × 400 = {p4:.3f}); validación t "
      f"{t_ic(IC4.loc[valida_, g4].to_numpy()):.2f}")
OUT [15]400 señales
máximo esperado por azar (N = 400): 2.98
ganadora -ret(21)*vol(5): IC 0.033, t 4.48 (p × 400 = 0.005); validación t -0.35

Con el doble de intentos, el máximo esperado por azar sube de 2,77 a 2,98 y aparece una ganadora nueva, -ret(21)*vol(5), con t = 4,48: tanto que supera incluso a Bonferroni (p × 400 = 0,005). En validación, t = −0,35. Su IC en el descubrimiento, 0,033, es mayor que el del alfa verdadero en el mismo periodo (0,021). O es suerte que las correcciones no recogen (suponen semanas independientes y colas normales), o es un efecto que existió en esos años y desapareció; en los dos casos, no hay nada que operar. Más intentos dan ganadoras más espectaculares, no mejores.

  1. Escribe una prueba point-in-time para la propuesta A (la ventana centrada). ¿En cuántas fechas de la muestra falla?
Solución
IN [16]python
exec(propuesta_a, espacio_agente)
tono_suave = espacio_agente["tono_suave"]
malas, celdas = prueba_pit(
    lambda n: tono_suave(tono_por_sesion(n)), muestra)
print(f"fechas con diferencias: {malas} de {len(muestra)}; "
      f"celdas: {celdas}")
OUT [16]La ventana centrada, en la prueba point-in-time
fechas con diferencias: 21 de 21; celdas: 427

La ventana centrada suma el tono de las dos sesiones siguientes, así que casi cualquier noticia posterior cambia la señal: falla en las 21 fechas y en 427 valores, frente a los 49 de la propuesta B. La revisión estática ya la había cazado; la prueba point-in-time la habría cazado igual sin saber nada de center=. Por eso la prueba dinámica es la que no se puede saltar.

  1. Un agente informa: «He encontrado una señal con t = 4,1 en 2019-2025 sobre estas 120 acciones». ¿Qué cinco preguntas le haces antes de mirar el gráfico?
Solución

(1) ¿Cuántas señales probaste en total, incluidas las descartadas y las de sesiones anteriores, y dónde está el registro? (2) ¿Cuál es el mecanismo, y lo declaraste antes de probarla? (3) ¿Pasa la prueba point-in-time y la revisión estática, y en qué instante se conoce cada dato que usa? (4) ¿Qué da en un periodo que no usaste para elegirla, y qué da el proceso de selección en validación cruzada por bloques? (5) ¿Cómo se compara con el techo (el oráculo de los datos o el orden de magnitud de la literatura) y qué queda después de costes (lección 5.4.4)? En estos datos, un t de 4,1 sería mayor que el del alfa verdadero en todo el periodo: la primera sospecha es la anticipación, como la propuesta B.

Respuesta plegada en cada pregunta

Autoevaluación

  1. De 200 señales probadas en el descubrimiento, la mejor tiene t = 3,29. ¿Qué es lo primero que hay que preguntarse?

    • ACuánto capital asignarle
    • BCuánto t daría el azar con 200 intentos: sin ninguna señal, el máximo esperado ronda 2,77, así que 3,29 está poco por encima y no basta
    • CSi el t es mayor que 1,96
    • DSi la señal es fácil de explicar
    Ver respuesta

    Respuesta B. Cuánto t daría el azar con 200 intentos: sin ninguna señal, el máximo esperado ronda 2,77, así que 3,29 está poco por encima y no basta

    El t de la ganadora no se compara con 1,96 sino con lo que daría el mejor de 200 intentos sin señal. En la validación, la ganadora de la lección cae a t = 0,10, y Bonferroni y Benjamini-Hochberg ya la descartaban.

  2. La ganadora tiene un Sharpe anual de 1,50 en descubrimiento. ¿Qué dice su Deflated Sharpe con los 200 intentos?

    • AQue es significativa, porque el PSR es 0,998
    • BQue el Deflated Sharpe no se aplica a señales de un agente
    • CQue hay que declarar menos intentos
    • DQue el umbral sube a un Sharpe de 1,48 y el DSR baja a 0,51: no se distingue del mejor de 200 intentos sin ventaja (0,84 con los 16 efectivos)
    Ver respuesta

    Respuesta D. Que el umbral sube a un Sharpe de 1,48 y el DSR baja a 0,51: no se distingue del mejor de 200 intentos sin ventaja (0,84 con los 16 efectivos)

    El PSR(0) de 0,998 trata la ganadora como si fuera el único intento. Corregido por selección, no pasa de 0,84 ni con el número de intentos efectivos, más favorable. En validación, su Sharpe es −0,06.

  3. ¿Por qué la lección valida el proceso de selección con validación cruzada por bloques en lugar de validar la señal ganadora?

    • APorque lo que se va a usar en el futuro es el proceso (probar muchas y quedarse con la mejor): su rendimiento fuera de muestra, 0,0042 de IC medio, es el que hay que esperar, no el de la ganadora
    • BPorque la ganadora no se puede validar
    • CPorque así se obtienen más señales
    • DPorque los bloques eliminan el ruido
    Ver respuesta

    Respuesta A. Porque lo que se va a usar en el futuro es el proceso (probar muchas y quedarse con la mejor): su rendimiento fuera de muestra, 0,0042 de IC medio, es el que hay que esperar, no el de la ganadora

    Si el agente seguirá minando y eligiendo, cada nueva ganadora sale del mismo proceso. Elegir con cinco bloques y medir en el sexto, seis veces, da su rendimiento real: 0,0042 frente a 0,0146 de tono(5), la señal con efecto plantado.

  4. La propuesta B del agente agrupa las noticias por su fecha de Nueva York. La revisión estática no da avisos. ¿Qué la delata?

    • AUn IC más bajo que el de la versión correcta
    • BQue el código es más corto
    • CLa prueba point-in-time: lo calculado para el viernes con todas las noticias no coincide con lo calculado con las publicadas antes del cierre del viernes (20 de 21 fechas cambian)
    • DUn error al ejecutarla
    Ver respuesta

    Respuesta C. La prueba point-in-time: lo calculado para el viernes con todas las noticias no coincide con lo calculado con las publicadas antes del cierre del viernes (20 de 21 fechas cambian)

    Las noticias publicadas tras el cierre llevan la fecha del día, pero se conocen después: con ellas, la señal del viernes usa información del fin de semana. No hay ningún patrón sospechoso en el texto del código; solo una prueba con fechas de publicación lo detecta.

  5. ¿Cuánto cambia la señal tono(5) con la alineación por fecha de Nueva York de la propuesta B?

    • ANada: es la misma señal
    • BSu t pasa de 3,08 a 4,39 y su IC de 0,0147 a 0,0214: la anticipación infla una señal real un 45 %
    • CBaja a cero
    • DCambia de signo
    Ver respuesta

    Respuesta B. Su t pasa de 3,08 a 4,39 y su IC de 0,0147 a 0,0214: la anticipación infla una señal real un 45 %

    La señal es real (efecto plantado) y la versión con anticipación la hace parecer mucho mejor. Es el caso más peligroso: un error que no convierte el ruido en señal, sino que exagera una señal buena y pasa cualquier sentido común.

Ideas clave

  • Un LLM es un generador de hipótesis barato y poco fiable: el modelo pequeño ignoró el lenguaje sin ejemplos (0 de 3 líneas válidas) y con ejemplos repitió y copió (4 distintas de 5, 2 copiadas), una con el signo contrario al efecto real.
  • Minar es probar muchas veces: de 200 señales, 10 tienen |t| > 1,96 (las esperadas por azar), ninguna pasa Bonferroni ni Benjamini-Hochberg, y la correlación entre el t del descubrimiento y el de validación es −0,08.
  • El t máximo esperado sin ninguna señal es 2,77 con 200 intentos. El alfa verdadero del nivel 5 da t = 2,36 en el mismo periodo: con 200 intentos, una señal real puede quedar por debajo del mejor ruido.
  • La ganadora (Sharpe 1,50 en descubrimiento) tiene un DSR de 0,51 con 200 intentos y un Sharpe de −0,06 en validación.
  • Se valida el proceso, no el ganador: el proceso de elegir la mejor da un IC de prueba de 0,0042 en validación cruzada por bloques; la señal con efecto plantado, 0,0146.
  • La revisión del código del agente tiene dos capas: la estática encuentra center=True; la prueba point-in-time encuentra la alineación por fecha que infla tono(5) de t = 3,08 a 4,39.

Recursos

Datos