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

Fig. — 200 signals from a simulated proposer on 120 stocks: weekly IC t-stat in discovery against validation
Level 6 lessons
  1. 6.1 · Classical and modern financial NLP

    1. 6.1.1 Tokenization, embeddings, financial dictionaries and FinBERT
    2. 6.1.2 Text sources: EDGAR, CNMV, transcripts and social media
    3. 6.1.3 Event studies with text signals, timestamp alignment and topic modeling
  2. 6.2 · LLMs for research and trading

    1. 6.2.1 How an LLM works: from the next token to the API
    2. 6.2.2 Structured prompting: JSON you can validate
    3. 6.2.3 Guidance, KPIs and tone: what an LLM extracts well
    4. 6.2.4 Lightweight fine-tuning for financial tasks: LoRA and QLoRA
    5. 6.2.5 Look-ahead bias in LLMs: the model that already knows the future
  3. 6.3 · Financial RAG

    1. 6.3.1 Embeddings and vector databases: FAISS, Chroma, pgvector and Qdrant
    2. 6.3.2 Chunking financial documents: tables, footnotes and XBRL
    3. 6.3.3 RAG over reports, prospectuses and KIDs, and how to evaluate it
  4. 6.4 · AI agents for trading

    1. 6.4.1 Tool-calling agents: tool calling, ReAct and planning
    2. 6.4.2 Agent frameworks and MCP
    3. 6.4.3 TradingAgents-style multi-agent architectures
    4. 6.4.4 Evidence and limits of agents
    5. 6.4.5 Guardrails for agents: the agent proposes, the engine decides
    6. 6.4.6 Research agents: hypotheses, alpha mining and review
  5. 6.5 · AI as the quant's copilot

    1. 6.5.1 Coding assistants with a test-first workflow
    2. 6.5.2 Automated review of backtests and code migrations
    3. 6.5.3 Risks of AI-generated code
  6. 6.6 · Big data for trading

    1. 6.6.1 When data becomes “big”: ticks, order books, options and universes
    2. 6.6.2 Parquet, Arrow, Polars streaming and out-of-core DuckDB
    3. 6.6.3 Dask, Spark and Ray: from one machine to a cluster
    4. 6.6.4 Time series databases: TimescaleDB, QuestDB, ClickHouse, ArcticDB and kdb+
    5. 6.6.5 Cloud storage, lakehouse (Delta, Iceberg) and GPU (RAPIDS, cuDF)
  7. 6.7 · Alternative data

    1. 6.7.1 Overview and size of the alternative data market
    2. 6.7.2 Types of alternative data: what each one measures
    3. 6.7.3 Responsible web scraping
    4. 6.7.4 Alternative data vendors and due diligence
    5. 6.7.5 From alternative data to a trading signal
    6. 6.7.6 Satellite imagery, computer vision and supply chains

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:

look-aheadcleanfailspassesHypothesisCodeReviewBacktestLogDSR and validationReport
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).

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)

# 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")
OUT [01]Prices, news and the session of each news item
120 stocks, 2011 sessions; 39570 news items, 0 without a company

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

IN [02]python
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%}")
OUT [02]Tone per session, without looking into the future
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:

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

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)        # 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)"))
OUT [03]A small language for signals
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:

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"valid: {sum(map(valida, lineas))} of {len(lineas)}")
OUT [04]The LLM as a hypothesis generator
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 3

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

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"valid {sum(map(valida, lineas))}, distinct {len(unicas)},"
      f" copied from the examples {len(copias)}: {copias}")
OUT [05]With examples: correct format, little variety
['-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.

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)} possible signals; {len(candidatas)} chosen")
print(candidatas[:6])
OUT [06]A simulated proposer: 200 signals
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).

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:]   # 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)
OUT [07]Weekly IC of the 200, with an embargo between periods
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.525

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

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]The LLM's proposals, measured
          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?

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()} 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")
OUT [09]How much does chance deliver among 200 trials?
|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.

Fig. — The highest t one expects to find with no signal at all, by how many are tried, against the winner of the 200 and the true alpha

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:

IN [10]python
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}")
OUT [10]The winner as a portfolio: Sharpe and Deflated Sharpe
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.06

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

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} 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}")
OUT [11]Validate the process, not the winner
                             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.0146
Fig. — Cross-validation of the selection process in six blocks with an embargo: test IC of the signal chosen in each fold against tono(5)

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

IN [12]python
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))
OUT [12]Two code proposals from the agent
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:

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):
    """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}")
OUT [13]The point-in-time test
tono_por_sesion  dates with differences: 0 of 21; cells: 0
tono_diario (B)  dates with differences: 20 of 21; cells: 49

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

IN [14]python
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}")
OUT [14]How much the look-ahead inflates
tono(5) correct      IC 0.0147  t 3.08
tono(5) proposal B   IC 0.0214  t 4.39

With 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 layerWhat it detectsWhat it does not detect
Closed languageArbitrary code, calls to disallowed dataErrors in the base functions
Static reviewcenter=True, shift(-k), bfillAlignments by date, joins with future data
Point-in-time testAny dependence on data after the cutoffData that already come with look-ahead from the vendor
Log and DSRSelection among many trialsA signal with look-ahead (lesson 5.4.4)
Process validationWinners that do not hold upAn 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

  1. 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
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"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}")
OUT [15]400 signals
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.35

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

  1. Write a point-in-time test for proposal A (the centered window). On how many dates of the sample does it fail?
Solution
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"dates with differences: {malas} of {len(muestra)}; "
      f"cells: {celdas}")
OUT [16]The centered window, in the point-in-time test
dates with differences: 21 of 21; cells: 427

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

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

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

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

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

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

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

Data