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 %.
- Módulo
- 6.4 · Agentes de IA para trading
- Duración
- 75 min
- Requisitos
- 2.1.8 · El problema de las pruebas múltiples: Bonferroni, FDR y el «factor zoo», 2.5.4 · Deflated Sharpe Ratio y Probability of Backtest Overfitting (PBO), 5.4.2 · Purged k-fold con embargo y CPCV para modelos de ML, 5.4.4 · Backtest de señales de ML con costes y Deflated Sharpe, 2.2.4 · Diario de investigación y registro de experimentos contra el data snooping
Lecciones del nivel 6
6.1 · NLP financiero clásico y moderno
6.2 · LLMs para investigación y trading
6.3 · RAG financiero
6.4 · Agentes de IA para trading
- 6.4.1 Agentes con herramientas: tool calling, ReAct y planificación
- 6.4.2 Marcos de agentes y MCP
- 6.4.3 Arquitecturas multiagente tipo TradingAgents
- 6.4.4 Evidencia y límites de los agentes
- 6.4.5 Guardarraíles para agentes: el agente propone, el motor decide
- 6.4.6 Agentes de investigación: hipótesis, alpha mining y revisión
6.5 · IA como copiloto del quant
6.6 · Big data para trading
- 6.6.1 Cuándo los datos son «big»: ticks, libros, opciones y universos
- 6.6.2 Parquet, Arrow, Polars en streaming y DuckDB fuera de memoria
- 6.6.3 Dask, Spark y Ray: de una máquina a un clúster
- 6.6.4 Bases de series temporales: TimescaleDB, QuestDB, ClickHouse, ArcticDB y kdb+
- 6.6.5 Almacenamiento en la nube, lakehouse (Delta, Iceberg) y GPU (RAPIDS, cuDF)
6.7 · Datos alternativos
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:
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).
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")120 acciones, 2011 sesiones; 39570 noticias, 0 sin empresaCada 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.
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%}")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ón | Qué 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.
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)"))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:
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)}")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 3Ninguna 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:
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}")['-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.
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])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).
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)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.525La 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:
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) 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?
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")|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.
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:
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}")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.06Como ú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:
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}") 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.0146El 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:
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))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:
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}")tono_por_sesion fechas con diferencias: 0 de 21; celdas: 0
tono_diario (B) fechas con diferencias: 20 de 21; celdas: 49La 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:
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}")tono(5) correcta IC 0.0147 t 3.08
tono(5) propuesta B IC 0.0214 t 4.39Con 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ón | Qué detecta | Qué no detecta |
|---|---|---|
| Lenguaje cerrado | Código arbitrario, llamadas a datos no permitidos | Errores en las funciones base |
| Revisión estática | center=True, shift(-k), bfill | Alineaciones por fecha, uniones con datos futuros |
| Prueba point-in-time | Cualquier dependencia de datos posteriores al corte | Datos que ya vienen con anticipación del proveedor |
| Registro y DSR | La selección entre muchos intentos | Una señal con anticipación (lección 5.4.4) |
| Validación del proceso | Ganadoras que no se sostienen | Un 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
- 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
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}")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.35Con 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.
- Escribe una prueba point-in-time para la propuesta A (la ventana centrada). ¿En cuántas fechas de la muestra falla?
Solución
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}")fechas con diferencias: 21 de 21; celdas: 427La 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.
- 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
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.
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.
¿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.
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.
¿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
Libros
- Harvey, C. R., Liu, Y. y Zhu, H. (2016): … and the Cross-Section of Expected Returns. Review of Financial Studies 29(1), 5-68
- Bailey, D. y López de Prado, M. (2014): The Deflated Sharpe Ratio. Journal of Portfolio Management 40(5), 94-107
- Kakushadze, Z. (2016): 101 Formulaic Alphas. Wilmott 2016(84), 72-80. arXiv:1601.00991
- Wang, S. et al. (2023): Alpha-GPT: Human-AI Interactive Alpha Mining for Quantitative Investment. arXiv:2308.00016
- Tang, Z. et al. (2025): AlphaAgent: LLM-Driven Alpha Mining with Regularized Exploration to Counteract Alpha Decay. arXiv:2502.16789