Lesson 6.4.6 · 75 min
Research agents: hypotheses, alpha mining and review
A research agent generates ideas, codes them and tests them faster than anyone. That multiplies the number of trials, and with it the probability of finding a spectacular signal by chance. This lesson sets an LLM and a simulated proposer to mine 200 signals on the 120 stocks of level 6, measures how much chance delivers, validates the selection process and not just the winner, and reviews the agent's code until it finds the look-ahead that inflates a real signal by 45%.
- Module
- 6.4 · AI agents for trading
- Duration
- 75 min
- Prerequisites
- 2.1.8 · The multiple testing problem: Bonferroni, FDR and the “factor zoo”, 2.5.4 · Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO), 5.4.2 · Purged k-fold with embargo and CPCV for ML models, 5.4.4 · Backtesting ML signals with costs and Deflated Sharpe, 2.2.4 · Research journal and experiment tracking against data snooping
- Author
- Jesús Cuesta
- Updated
Level 6 lessons
6.1 · Classical and modern financial NLP
6.2 · LLMs for research and trading
6.3 · Financial RAG
6.4 · AI agents for trading
- 6.4.1 Tool-calling agents: tool calling, ReAct and planning
- 6.4.2 Agent frameworks and MCP
- 6.4.3 TradingAgents-style multi-agent architectures
- 6.4.4 Evidence and limits of agents
- 6.4.5 Guardrails for agents: the agent proposes, the engine decides
- 6.4.6 Research agents: hypotheses, alpha mining and review
6.5 · AI as the quant's copilot
6.6 · Big data for trading
- 6.6.1 When data becomes “big”: ticks, order books, options and universes
- 6.6.2 Parquet, Arrow, Polars streaming and out-of-core DuckDB
- 6.6.3 Dask, Spark and Ray: from one machine to a cluster
- 6.6.4 Time series databases: TimescaleDB, QuestDB, ClickHouse, ArcticDB and kdb+
- 6.6.5 Cloud storage, lakehouse (Delta, Iceberg) and GPU (RAPIDS, cuDF)
6.7 · Alternative data
What a research agent does
A research agent does not trade: it reads, proposes hypotheses, writes the code that tests them, runs the backtest and writes up what it has found. Lessons 6.4.1 to 6.4.3 teach how to build one with tools and several steps. This lesson deals with what happens afterwards, when it works: an agent that tests a hundred ideas in an afternoon turns research into signal mining (alpha mining), and mining has two risks known since long before LLMs.
The first is multiple testing (lessons 2.1.8 and 2.5.4): with enough trials, a spectacular signal always appears by chance. The second is code with look-ahead (lessons 0.2.6 and 5.2.2): the agent writes plausible code that uses information that did not exist at the decision date. Both produce better backtests, which is exactly what the agent is trying to achieve. The research cycle therefore needs two gates the agent cannot open:
Mermaid source
flowchart TB
H[Hypothesis] --> C[Code]
C --> R{Review}
R -->|look-ahead| C
R -->|clean| B[Backtest]
B --> L[Log]
L --> D{DSR and validation}
D -->|fails| H
D -->|passes| P[Report]The code review goes before the backtest; the log of all trials (lesson 2.2.4) and the correction for selection go after, and they are only worth anything if the log is complete. The literature on alpha mining with LLMs is recent (Alpha-GPT, by Wang et al., 2023; AlphaAgent, by Tang et al., 2025, which adds originality and complexity controls against overfitting and alpha decay); both gates apply to any of those systems, because they do not depend on how the ideas are generated.
The data and the news signal
The test bench is the prices of the 120 stocks and the 39,570 news items of the level 6 corpus, with the dictionary
tone of lesson 6.1.1: (positive − negative) / (positive + negative + 1) with the invented financial lexicon of the
data. The corpus and the lexicon are in Spanish, and they stay that way: the tone is computed on Spanish words. News
about companies that changed ticker carries the ticker of its date; it is translated to the current one with the
ticker history.
The delicate part is which session each news item belongs to. A news item published at 18:00 New York time cannot
be used to trade at that day's close, which was at 16:00. The ia environment does not have exchange_calendars, so
the cell sets the closes by hand: 16:00 New York time except on the 15 half sessions from 2019 to 2025, which close at
13:00 (the dates come from exchange_calendars 4.13.2, XNYS calendar, in the main environment).
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)
# Close of each session: 16:00 New York time, except the 15 half
# sessions of 2019-2025 (13:00), according to 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]} stocks, {len(sesiones)} sessions; "
f"{len(noticias)} news items, "
f"{noticias['tk'].isna().sum()} without a company")120 stocks, 2011 sessions; 39570 news items, 0 without a companyEach news item counts in the first session that closes after its publication (searchsorted with
side="right"): what is published after the close, on a weekend or on a holiday moves to the next one.
def tono_por_sesion(noticias):
"""Each news item counts in the first session that closes AFTER
it is published: what is published after the close, in the next."""
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"news items whose session is not their New York date: "
f"{distinta:.0%}")news items whose session is not their New York date: 33%A third of the news belongs to a session other than its New York date. It is the fact that will be needed in the review of the agent's code.
A closed language for signals
Letting an agent write arbitrary Python code for each idea is the fastest way to multiply errors. The usual alternative in alpha mining (Kakushadze's 101 alphas, 2016, are formulas like these) is a small signal language: a few data functions and a few operators, evaluated by an interpreter that has been reviewed. Here:
| Function | What it computes (at each close) |
|---|---|
ret(n) | Return over the last n sessions |
vol(n) | Standard deviation of daily returns over n sessions |
volu(n) | Log of the n-session average volume over the 63-session one |
tono(n) | Sum of the news tone over the last n sessions |
Operators: - (unary and binary), * and /. Each function returns its weekly cross-sectional normal rank (the
transformation of lesson 5.2.4), so combinations are made between comparable scores. The evaluator walks Python's
syntax tree and rejects anything that is not part of the language: there is no eval, no attributes and no calls to
other functions.
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) # close of each week
Y = (fin.shift(-1) / fin - 1).reindex(W).to_numpy() # the next one
def g(df):
"""Weekly cross-sectional normal rank."""
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):
"""Evaluates a signal of the language; error if it is not in it."""
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"outside the language: {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), "weeks;", valida("-ret(5)"), valida("ret(5) + 1"),
valida("**Momentum**: ret(252)"))365 weeks; True False False-ret(5) is valid; ret(5) + 1 is not (there is no addition and no loose constants), and neither is a line with text
around it: as in lesson 6.4.5, the evaluator validates, it does not repair.
The LLM as a hypothesis generator
The first idea is to ask an LLM for hypotheses. The lesson's is the small local model of the ia environment
(Qwen2.5-0.5B-Instruct, greedy decoding). The prompts are in Spanish, like the rest of the level's corpus, and stay
that way because they determine what the model writes. First, with the description of the language and no examples:
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"valid: {sum(map(valida, lineas))} of {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
valid: 0 of 3No line belongs to the language: the model writes sentences about “el horizonte de la semana” (the week's horizon) with invented figures, and the 80 tokens run out before the fifth. With a small model, a format instruction without examples is of little use. With five examples in the conversation (few-shot, lesson 6.2.2) the format improves:
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"valid {sum(map(valida, lineas))}, distinct {len(unicas)},"
f" copied from the examples {len(copias)}: {copias}")['-ret(5)', '-tono(5)', '-ret(5)', '-vol(63)', '-volu(5)']
valid 5, distinct 4, copied from the examples 2: ['-ret(5)', '-vol(63)']All five lines belong to the language, but there are only four distinct ones: two are copies of the examples
(-ret(5), which also appears twice, and -vol(63)) and the other two are pieces of the examples with a minus sign in
front. It is a general trait of generation with examples, not only of the small model: proposals anchor on what they
are shown. A large model proposes more varied and better reasoned ideas, but the closed language, the validation and
the removal of duplicates are still needed. And the quality of the ideas is measured afterwards, with the data, like
those of any other proposer.
A simulated proposer: 200 signals
To study what happens when an agent tests many ideas, the ideas do not need to be good: they need to be many. The simulated proposer enumerates all signals of one or two functions (450 with 15 base functions) and picks 200 at random, with a seed. A real agent proposes more reasonable ideas, but also more similar to each other; what matters for multiple testing is the number of trials and their correlation.
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)} possible signals; {len(candidatas)} chosen")
print(candidatas[:6])450 possible signals; 200 chosen
['volu(5)*volu(21)', '-ret(10)*tono(5)', 'vol(5)-vol(21)', '-tono(21)', '-ret(1)*vol(5)', 'tono(21)-volu(5)']Each signal is measured with its weekly IC: the rank correlation between the signal at the close of the last day of each week and the return to the close of the following week, across the 120 stocks. The period is split in two: discovery (from 2019 to June 2022), where the agent chooses, and validation (from July 2022 to 2025), which is not looked at until the end. A one-week embargo is left between the two: the label of the last discovery week ends on the day validation begins. With non-overlapping one-week labels, one is enough; with 21-session labels, four would have to be purged (lesson 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:] # its label ends in the 1st validation week
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"discovery: {descubre.sum()} weeks up to "
f"{W[descubre][-1].date()}; validation: {valida_.sum()} from "
f"{W[valida_][0].date()}")
res.sort_values("t desc.", ascending=False).head(10).round(3)discovery: 181 weeks up to 2022-06-17; validation: 183 from 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.525The discovery winner, -ret(21)*vol(21) (one-month reversal in volatile stocks), has t = 3.29. In validation,
t = 0.10. The second, the third and almost all the others in the top 10 fall in the same way. Only two hold up
somewhat: tono(5), the news signal with a planted effect, which goes from 2.33 to 1.89, and -ret(10)*vol(21), from
2.42 to 1.89, a relative of the level 5 panel's reversal. The header figure puts the 200 on the plane: the cloud has no
slope at all (the correlation between the discovery t and the validation t is −0.08), and the winner sits in the corner
where the maximum of a cloud of noise is expected to be.
The LLM's four proposals, measured the same way:
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) has t = −2.33 and −1.89: the model proposed the news signal with the sign opposite to the real effect,
because it copied the example tono(5) with the minus-sign pattern of the others. The other three cannot be told apart
from zero.
How much chance delivers among 200 trials
Three ways of asking the same thing: is the winner better than what chance would give with so many trials?
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()} of {m}"
f" (by chance {0.05 * m:.0f} would be expected)")
print(f"Bonferroni at 5%: {(p < 0.05 / m).sum()}; "
f"Benjamini-Hochberg at 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"effective trials: {n_ef:.0f}; maximum t expected with no "
f"signal at all: {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"level 5 oracle: IC {ic_or[descubre].mean():.3f} "
f"(t {t_ic(ic_or[descubre].to_numpy()):.2f}) in discovery,"
f" {ic_or[valida_].mean():.3f} in validation")|t| > 1.96: 10 of 200 (by chance 10 would be expected)
Bonferroni at 5%: 0; Benjamini-Hochberg at 10%: 0
effective trials: 16; maximum t expected with no signal at all: 2.77 (N = 200), 1.81 (N = 16)
level 5 oracle: IC 0.021 (t 2.36) in discovery, 0.029 in validation- Count: 10 of 200 signals have |t| > 1.96, exactly those expected by chance at 5%.
- Corrections (lesson 2.1.8): neither Bonferroni nor Benjamini-Hochberg at 10% lets any through.
- Expected maximum: with no signal at all, the best t of 200 independent trials would be around 2.77. The 200 are correlated (they share functions), and the participation ratio of the eigenvalues of their correlation gives about 16 effective trials, with an expected maximum of 1.81. With the most favorable count, the winner's 3.29 is above it; with the honest one, only a little above.
The last line is the most uncomfortable. The true alpha of the level 5 panel, the oracle that knows the planted effect, has an IC of 0.021 and a t of 2.36 in discovery. The winner has an IC of 0.024, higher than the oracle's: that is impossible except by luck. And a real signal with t = 2.36 falls below the maximum that noise gives with 200 trials. Mining a lot does not only generate false positives: it also buries the true signals among them.
The figure puts the three things together: the curve of the maximum expected by chance as a function of the number of trials, the winner's t and the line of the true alpha. From about 60 trials on, noise beats the true signal on average.
The winner as a portfolio: Deflated Sharpe
The IC is not money. The natural portfolio is long the top quintile of the signal and short the bottom one, equally weighted and rebalanced every week (without costs: here only selection matters; with costs, lesson 5.4.4). Its Sharpe is corrected with the Deflated Sharpe of lesson 2.5.4, with the dispersion of the Sharpes of the 200 portfolios:
def larga_corta(score, q=0.2):
"""Top quintile minus bottom quintile, equally weighted, weekly."""
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}: threshold {sr0 * np.sqrt(52):.2f} annual,"
f" DSR = {psr(sr[mejor], sr0, x):.3f}")
for nombre, z in (("discovery", x),
("validation", carteras.loc[valida_, mejor])):
print(f"{mejor}, {nombre}: annual Sharpe "
f"{z.mean() / z.std() * np.sqrt(52):.2f}")N = 1: threshold 0.00 annual, DSR = 0.998
N = 16: threshold 0.97 annual, DSR = 0.841
N = 200: threshold 1.48 annual, DSR = 0.514
-ret(21)*vol(21), discovery: annual Sharpe 1.50
-ret(21)*vol(21), validation: annual Sharpe -0.06As a single trial (PSR with a zero threshold), the annual Sharpe of 1.50 over three and a half years looks conclusive: 0.998. With the 16 effective trials, the threshold rises to a Sharpe of 0.97 and the DSR falls to 0.84; with the 200 declared, the threshold is 1.48, almost the winner's Sharpe, and the DSR, 0.51. In validation, the Sharpe is −0.06. The DSR did not know what would happen in validation: it said the winner could not be told apart from the best of 200 trials without an edge, and that was true.
Validate the process, not the winner
The validation of the previous section has a practical problem: it can only be done once. If the agent looks at the validation, chooses another signal and looks again, validation becomes discovery. And, above all, what will be used in the future is not today's winner, but the process: the agent will keep testing 200 ideas and keeping the best.
So what has to be validated is the process. The cell splits the 365 weeks into six blocks and, for each one, chooses the best signal with the other five (leaving a one-week embargo on each side of the test block, as in the purged k-fold of lesson 5.4.2) and measures its IC on the block that was left out:
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} to "
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"mean: chosen {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 to 2020-02 tono(5) 2.86 0.0111 0.0111
2020-03 to 2021-04 -ret(10)*vol(21) 3.41 0.0002 0.0171
2021-05 to 2022-07 -ret(10)*vol(63) 3.03 0.0024 0.0174
2022-07 to 2023-09 -ret(10)*vol(21) 3.20 0.0063 0.0106
2023-09 to 2024-11 -ret(10)*vol(21) 3.19 0.0028 0.0295
2024-11 to 2025-12 tono(5) 3.29 0.0021 0.0021
mean: chosen 0.0042; tono(5) 0.0146The process of choosing the best of 200 gives a mean out-of-sample IC of 0.0042. The news signal, fixed in advance,
0.0146: more than three times as much. When the process chooses tono(5) (two of the six blocks) it gets the same as
the signal; in the other four it chooses volatility-reversal variants with training t above 3 that, outside the
block, give between 0.0002 and 0.0063.
The conclusion of the section: a hypothesis with a mechanism (news is incorporated into the price with a delay, lesson 6.1.3), declared before looking at the data, is worth more than the best of 200 combinations without a mechanism, even if the latter has a better in-sample t. A useful research agent proposes hypotheses with a mechanism and declares them before testing them; one that simply mines produces, above all, in-sample winners.
Reviewing the agent's code
Multiple testing is a problem of how many ideas are tested. Look-ahead is a problem of how each one is coded, and a closed language does not entirely prevent it: someone has to code the base functions, and the agent also proposes new functions. Two typical proposals for the news signal:
propuesta_a = """
def tono_suave(T):
# smooths the tone to remove noise
return T.rolling(5, center=True).sum()
"""
propuesta_b = """
def tono_diario(noticias):
# tone of each New York day, per company
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):
"""Look-ahead patterns that can be seen in the code's text."""
avisos = []
for n in ast.walk(ast.parse(codigo)):
if isinstance(n, ast.keyword) and n.arg == "center":
avisos.append(f"line {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"line {n.lineno}: negative shift")
if isinstance(n, ast.Attribute) and n.attr in ("bfill",
"backfill"):
avisos.append(f"line {n.lineno}: {n.attr}")
return avisos or ["no warnings"]
for nombre, codigo in (("A", propuesta_a), ("B", propuesta_b)):
print(nombre, revision_estatica(codigo))A ['line 4: center=']
B ['no warnings']The static review walks the syntax tree looking for known look-ahead patterns: center= in a rolling window,
shift with a negative argument, bfill. It finds proposal A's centered window, which adds the tone of the next two
sessions. Proposal B comes out clean: grouping by date has nothing suspicious in the text.
Proposal B has the error the level 6 README documents: it assigns each news item to its New York date, so a news item published on Friday at 18:00 counts in Friday's signal, computed at the 16:00 close. Seeing it requires a point-in-time test: what the function computes for date D with all the news has to match what it computes with the news published before D's close. It is the truncation test of lesson 0.2.6, but cutting by the publication instant, not by the date:
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):
"""What is computed for date D with all the news has to match
what is computed with the news published before D's close.
Returns the dates and the cells that change."""
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 Fridays spread out
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} dates with differences: {malas} of "
f"{len(muestra)}; cells: {celdas}")tono_por_sesion dates with differences: 0 of 21; cells: 0
tono_diario (B) dates with differences: 20 of 21; cells: 49The correct function gives the same on the 21 dates of the sample. Proposal B changes on 20 of 21: 49 values of Friday's signal depend on news published after the close. What it costs not to see it:
for nombre, T_ in (("correct", T),
("proposal 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) correct IC 0.0147 t 3.08
tono(5) proposal B IC 0.0214 t 4.39With proposal B's alignment, the IC of tono(5) rises from 0.0147 to 0.0214 and its t from 3.08 to 4.39: 45% more. It
is the most dangerous case of look-ahead. It does not turn noise into signal (that is detected for being implausible):
it exaggerates a real signal, and the result still looks reasonable. News on Friday afternoon moves the price on
Monday, which is inside the label's week.
| Review layer | What it detects | What it does not detect |
|---|---|---|
| Closed language | Arbitrary code, calls to disallowed data | Errors in the base functions |
| Static review | center=True, shift(-k), bfill | Alignments by date, joins with future data |
| Point-in-time test | Any dependence on data after the cutoff | Data that already come with look-ahead from the vendor |
| Log and DSR | Selection among many trials | A signal with look-ahead (lesson 5.4.4) |
| Process validation | Winners that do not hold up | An effect that changes in the future |
Lesson 6.5.2 automates the review of complete backtests; lesson 6.5.3 covers the other risks of generated code. The checklist of lesson 2.5.8 still applies to everything the agent delivers.
Exercises
- Repeat the mining with twice as many signals (400 of the 450) and the same seed. How much does the maximum expected by chance rise? What t does the winner have in discovery and in validation, and does it pass Bonferroni?
Solution
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"maximum expected by chance (N = 400): {max_esperado(400):.2f}")
p4 = 2 * t_dist.sf(t4.max(), n_d - 1) * 400
print(f"winner {g4}: IC {IC4.loc[descubre, g4].mean():.3f}, "
f"t {t4.max():.2f} (p × 400 = {p4:.3f}); validation t "
f"{t_ic(IC4.loc[valida_, g4].to_numpy()):.2f}")maximum expected by chance (N = 400): 2.98
winner -ret(21)*vol(5): IC 0.033, t 4.48 (p × 400 = 0.005); validation t -0.35With twice as many trials, the maximum expected by chance rises from 2.77 to 2.98 and a new winner appears,
-ret(21)*vol(5), with t = 4.48: so high that it even passes Bonferroni (p × 400 = 0.005). In validation, t = −0.35.
Its IC in discovery, 0.033, is higher than that of the true alpha over the same period (0.021). Either it is luck that
the corrections do not capture (they assume independent weeks and normal tails), or it is an effect that existed in
those years and disappeared; in both cases, there is nothing to trade. More trials give more spectacular winners, not
better ones.
- Write a point-in-time test for proposal A (the centered window). On how many dates of the sample does it fail?
Solution
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"dates with differences: {malas} of {len(muestra)}; "
f"cells: {celdas}")dates with differences: 21 of 21; cells: 427The centered window adds the tone of the next two sessions, so almost any later news item changes the signal: it
fails on all 21 dates and in 427 values, against proposal B's 49. The static review had already caught it; the
point-in-time test would have caught it just the same without knowing anything about center=. That is why the
dynamic test is the one that cannot be skipped.
- An agent reports: “I have found a signal with t = 4.1 in 2019-2025 on these 120 stocks”. What five questions do you ask it before looking at the chart?
Solution
(1) How many signals did you test in total, including the discarded ones and those from previous sessions, and where is the log? (2) What is the mechanism, and did you declare it before testing the signal? (3) Does it pass the point-in-time test and the static review, and at what instant is each piece of data it uses known? (4) What does it give over a period you did not use to choose it, and what does the selection process give in blocked cross-validation? (5) How does it compare with the ceiling (the oracle of the data or the order of magnitude in the literature) and what is left after costs (lesson 5.4.4)? In these data, a t of 4.1 would be higher than that of the true alpha over the whole period: the first suspicion is look-ahead, like proposal B.
Each answer is folded under its question
Self-assessment
Of 200 signals tested in discovery, the best has t = 3.29. What is the first thing to ask?
- AHow much capital to allocate to it
- BHow much t chance would give with 200 trials: with no signal at all, the expected maximum is around 2.77, so 3.29 is only a little above and is not enough
- CWhether the t is greater than 1.96
- DWhether the signal is easy to explain
Show answer
Answer B. How much t chance would give with 200 trials: with no signal at all, the expected maximum is around 2.77, so 3.29 is only a little above and is not enough
The winner's t is not compared with 1.96 but with what the best of 200 trials without signal would give. In validation, the lesson's winner falls to t = 0.10, and Bonferroni and Benjamini-Hochberg had already discarded it.
The winner has an annual Sharpe of 1.50 in discovery. What does its Deflated Sharpe say with the 200 trials?
- AThat it is significant, because the PSR is 0.998
- BThat the Deflated Sharpe does not apply to an agent's signals
- CThat fewer trials have to be declared
- DThat the threshold rises to a Sharpe of 1.48 and the DSR falls to 0.51: it cannot be told apart from the best of 200 trials without an edge (0.84 with the 16 effective ones)
Show answer
Answer D. That the threshold rises to a Sharpe of 1.48 and the DSR falls to 0.51: it cannot be told apart from the best of 200 trials without an edge (0.84 with the 16 effective ones)
The PSR(0) of 0.998 treats the winner as if it were the only trial. Corrected for selection, it does not exceed 0.84 even with the effective number of trials, the more favorable one. In validation, its Sharpe is −0.06.
Why does the lesson validate the selection process with blocked cross-validation instead of validating the winning signal?
- ABecause what will be used in the future is the process (test many and keep the best): its out-of-sample performance, a mean IC of 0.0042, is what should be expected, not the winner's
- BBecause the winner cannot be validated
- CBecause that way more signals are obtained
- DBecause the blocks remove the noise
Show answer
Answer A. Because what will be used in the future is the process (test many and keep the best): its out-of-sample performance, a mean IC of 0.0042, is what should be expected, not the winner's
If the agent keeps mining and choosing, each new winner comes out of the same process. Choosing with five blocks and measuring on the sixth, six times, gives its real performance: 0.0042 against 0.0146 for tono(5), the signal with a planted effect.
The agent's proposal B groups the news by its New York date. The static review gives no warnings. What gives it away?
- AA lower IC than the correct version's
- BThat the code is shorter
- CThe point-in-time test: what is computed for Friday with all the news does not match what is computed with the news published before Friday's close (20 of 21 dates change)
- DAn error when running it
Show answer
Answer C. The point-in-time test: what is computed for Friday with all the news does not match what is computed with the news published before Friday's close (20 of 21 dates change)
News published after the close carries the day's date, but becomes known later: with it, Friday's signal uses weekend information. There is no suspicious pattern in the code's text; only a test with publication times detects it.
How much does the tono(5) signal change with proposal B's alignment by New York date?
- ANot at all: it is the same signal
- BIts t goes from 3.08 to 4.39 and its IC from 0.0147 to 0.0214: the look-ahead inflates a real signal by 45%
- CIt falls to zero
- DIt changes sign
Show answer
Answer B. Its t goes from 3.08 to 4.39 and its IC from 0.0147 to 0.0214: the look-ahead inflates a real signal by 45%
The signal is real (planted effect) and the version with look-ahead makes it look much better. It is the most dangerous case: an error that does not turn noise into signal, but exaggerates a good signal and passes any common-sense check.
Key points
- An LLM is a cheap and unreliable hypothesis generator: the small model ignored the language without examples (0 of 3 valid lines) and with examples repeated and copied (4 distinct out of 5, 2 copied), one with the sign opposite to the real effect.
- Mining is testing many times: of 200 signals, 10 have |t| > 1.96 (those expected by chance), none passes Bonferroni or Benjamini-Hochberg, and the correlation between the discovery t and the validation t is −0.08.
- The maximum t expected with no signal at all is 2.77 with 200 trials. The true alpha of level 5 gives t = 2.36 over the same period: with 200 trials, a real signal can end up below the best noise.
- The winner (Sharpe 1.50 in discovery) has a DSR of 0.51 with 200 trials and a Sharpe of −0.06 in validation.
- The process is validated, not the winner: the process of choosing the best gives a test IC of 0.0042 in blocked cross-validation; the signal with a planted effect, 0.0146.
- The review of the agent's code has two layers: the static one finds
center=True; the point-in-time test finds the date alignment that inflates tono(5) from t = 3.08 to 4.39.
Resources
Books
- Harvey, C. R., Liu, Y. and Zhu, H. (2016): … and the Cross-Section of Expected Returns. Review of Financial Studies 29(1), 5-68
- Bailey, D. and 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