Lección 6.1.3 · 75 min
Event studies con señales de texto, alineación temporal y topic modeling
Una señal de texto solo vale lo que mueve el precio después de poder operarla. Esta lección convierte el instante de publicación de cada noticia en su sesión de reacción con el calendario real de la NYSE, mide cuánto se equivoca quien usa la fecha, estudia la rentabilidad anormal por tono, agrupa las noticias por tema y repite el método con los cambios en los factores de riesgo de los 10-K.
- Módulo
- 6.1 · NLP financiero clásico y moderno
- Duración
- 75 min
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
Un estudio de eventos con texto
La lección 2.3.5 construyó estudios de eventos con fechas: anuncios de resultados, altas en un índice, dividendos. La mecánica es la misma con texto (rentabilidad anormal, CAR, ventanas antes y después), con dos diferencias que cambian los resultados:
- El evento tiene hora. Una noticia de las 7:00 y otra de las 17:40 del mismo día no entran en el mismo rendimiento de cierre a cierre. Con datos diarios, la pregunta es en qué sesión puede reaccionar el precio.
- La señal es una puntuación ruidosa. El sentimiento sale de un diccionario, de FinBERT o de un LLM (lección 6.1.1), no de una cifra contable. Y hay muchos más eventos: decenas de miles de noticias, con copias y crónicas que no son eventos nuevos.
Fuente Mermaid
flowchart LR P[Publicación] --> S[Sesión de reacción] S --> D[Deriva] C[Control previo] --> P
El control previo (las sesiones −5 a −1) tiene que salir plano: si la CAR se mueve antes de la publicación, o hay filtración, o la alineación está mal, o la noticia cuenta algo que ya había pasado.
Los datos son los del nivel 6: 39.570 noticias sintéticas con su instante de publicación al segundo y los cierres de 120 acciones ficticias en 2.011 sesiones de la NYSE. El generador plantó un efecto conocido: el día de reacción, cada noticia mueve el precio en proporción a su sentimiento verdadero y a la materialidad del evento, con una deriva decreciente en las cinco sesiones siguientes. La pendiente plantada es de 123 pb por unidad de sentimiento (README del nivel 6). La rentabilidad anormal es la de la acción menos la media de las 120 del panel:
import re
import exchange_calendars as xcals
import numpy as np
import pandas as pd
noticias = pd.read_parquet("noticias6_corpus.parquet")
verdad = pd.read_csv("noticias6_verdad.csv")
df = noticias.merge(verdad, on="id_noticia")
precios = pd.read_parquet("acciones6_precios.parquet")
cierre = precios.pivot(index="fecha", columns="ticker",
values="cierre")
r = cierre.pct_change()
anormal = r.sub(r.mean(axis=1), axis=0).to_numpy()
N = len(cierre)
columna = {t: j for j, t in enumerate(cierre.columns)}
df["j"] = df["ticker_actual"].map(columna)
print(cierre.shape, df.shape)
print(df["tipo_noticia"].value_counts().to_dict())(2011, 120) (39570, 22)
{'original': 22593, 'sindicada': 12266, 'resultados': 3360, 'mercado': 1209, 'correccion': 142}ticker_actual es el ticker vigente hoy, el que usa la tabla de precios: seis empresas cambiaron de ticker y sus
noticias antiguas llevan el viejo (lección 6.1.2). Unir por el ticker de la noticia perdería esas filas.
Del instante a la sesión de reacción
Un precio de cierre resume lo que se sabía a las 16:00 de Nueva York. Una noticia publicada en el instante solo puede entrar en los rendimientos de las sesiones cuyo cierre es posterior a . Con el calendario de la bolsa (lección 1.1.2), la sesión de reacción es la primera sesión que cierra después de la publicación:
| Publicación (hora de Nueva York) | Sesión de reacción | Fracción del efecto ese día |
|---|---|---|
| Antes de la apertura (6:00-9:29) | La de ese día | Todo |
| En sesión (9:30-15:59) | La de ese día | La parte que queda de sesión; el resto, la siguiente |
| Tras el cierre o de noche | La siguiente | Todo |
| Fin de semana o festivo | La siguiente | Todo |
| Media sesión, después de las 13:00 | La siguiente | Todo |
exchange_calendars da la hora de apertura y de cierre de cada sesión en UTC, con las medias sesiones (el día
después de Acción de Gracias, Nochebuena y algunas vísperas del 4 de julio cierran a las 13:00) y los cambios de
horario de verano incluidos. Con esa tabla, la sesión de reacción es una búsqueda binaria:
cal = xcals.get_calendar("XNYS")
horario = cal.schedule.loc[cierre.index[0]:cierre.index[-1]]
print((horario.index.tz_localize(None) == cierre.index).all())
cierres_us = pd.DatetimeIndex(horario["close"]).as_unit("us").asi8
aperturas_us = pd.DatetimeIndex(horario["open"]).as_unit("us").asi8
hora_cierre = horario["close"].dt.tz_convert("America/New_York")
medias = horario[hora_cierre.dt.hour < 16]
print(len(medias), "medias sesiones")
medias.loc["2019-11-29":"2019-12-24"]True
18 medias sesiones
open break_start break_end close
2019-11-29 2019-11-29 14:30:00+00:00 NaT NaT 2019-11-29 18:00:00+00:00
2019-12-24 2019-12-24 14:30:00+00:00 NaT NaT 2019-12-24 18:00:00+00:00Las 2.011 sesiones del calendario coinciden con las de la tabla de precios. El 29 de noviembre de 2019 cierra a las 18:00 UTC (13:00 en Nueva York): una noticia de las 14:00 de ese día ya no entra en su rendimiento.
def sesion_de(ts):
"""Primera sesión que cierra después de `ts` y si `ts` cae
dentro de esa sesión (entre su apertura y su cierre)."""
t = pd.DatetimeIndex(ts).as_unit("us").asi8
k = np.searchsorted(cierres_us, t, side="right")
dentro = t > aperturas_us[np.minimum(k, N - 1)]
return k, dentro
df["k"], df["en_sesion"] = sesion_de(df["ts"])
calculada = cierre.index[np.minimum(df["k"], N - 1)]
coincide = (calculada.strftime("%Y-%m-%d")
== df["sesion_reaccion"])
con_verdad = df["sesion_reaccion"].notna() & (df["k"] < N)
print(f"coincide con la verdad: {coincide[con_verdad].mean():.1%}",
f"de {con_verdad.sum()} noticias")
ny = df["ts"].dt.tz_convert("America/New_York")
hora = ny.dt.hour + ny.dt.minute / 60
dia_con_sesion = ny.dt.tz_localize(None).dt.normalize().isin(
cierre.index)
df["franja"] = np.select(
[~dia_con_sesion, hora < 9.5, df["en_sesion"], hora < 20],
["sin sesión", "antes de abrir", "en sesión", "tras el cierre"],
"noche")
orig = df[(df["tipo_noticia"] == "original")
& (df["k"] >= 6) & (df["k"] < N - 12)].copy()
orig["franja"].value_counts(normalize=True).round(3)coincide con la verdad: 100.0% de 27304 noticias
franja
en sesión 0.407
antes de abrir 0.280
tras el cierre 0.181
sin sesión 0.088
noche 0.044
Name: proportion, dtype: float64La sesión calculada coincide con la del archivo de verdad en las 27.304 noticias que la tienen (las copias
sindicadas no llevan sesión propia en ese archivo). Un 18 % de las noticias originales
sale tras el cierre, un 4 % de noche y un 9 % en días sin sesión (fines de semana y festivos): casi un tercio del
corpus tiene su efecto en una sesión distinta de la de su fecha. Las noticias con k fuera de la muestra (las
primeras seis sesiones y las doce últimas) se quedan fuera para que todas las ventanas de evento estén completas.
El error de alinear por fecha, medido
El día de reacción es la sesión de reacción y, si la noticia salió en sesión, también la siguiente (donde cae el resto del efecto). La pendiente de su rentabilidad anormal sobre el sentimiento verdadero mide cuánto mueve el precio una unidad de sentimiento. Con la alineación correcta y con dos ingenuas: la fecha de Nueva York de la noticia y su fecha UTC, cada una con el rendimiento de esa fecha:
def pendiente_pb(x, y):
x, y = np.asarray(x, float), np.asarray(y, float)
b, a = np.polyfit(x, y, 1)
resid = y - a - b * x
se = np.sqrt(resid @ resid / (len(x) - 2)
/ ((x - x.mean()) ** 2).sum())
return float(round(1e4 * b, 1)), float(round(1e4 * se, 1))
def dia_reaccion(d):
k, j = d["k"].to_numpy(), d["j"].to_numpy()
siguiente = anormal[np.minimum(k + 1, N - 1), j]
return anormal[k, j] + np.where(d["en_sesion"], siguiente, 0)
def por_fecha(d, fechas):
i = cierre.index.get_indexer(fechas)
return np.where(i >= 0, anormal[np.maximum(i, 0), d["j"]], np.nan)
orig["y_reaccion"] = dia_reaccion(orig)
ny_o = orig["ts"].dt.tz_convert("America/New_York")
orig["y_fecha_ny"] = por_fecha(orig, ny_o.dt.tz_localize(None)
.dt.normalize())
orig["y_fecha_utc"] = por_fecha(orig, orig["ts"].dt.tz_localize(None)
.dt.normalize())
s = orig["sentimiento_verdadero"]
filas = {}
for c in ["y_reaccion", "y_fecha_ny", "y_fecha_utc"]:
ok = orig[c].notna()
filas[c] = (int(ok.sum()), *pendiente_pb(s[ok], orig.loc[ok, c]))
ok = orig["y_fecha_ny"].notna()
mismas = pendiente_pb(s[ok], orig.loc[ok, "y_reaccion"])
filas["y_reaccion, mismas"] = (int(ok.sum()), *mismas)
pd.DataFrame(filas, index=["noticias", "pb", "error_tipico"]).T noticias pb error_tipico
y_reaccion 22518.0 118.8 3.0
y_fecha_ny 20540.0 61.4 2.7
y_fecha_utc 20341.0 68.5 2.7
y_reaccion, mismas 20540.0 117.4 3.2Con la sesión de reacción, la pendiente es de unos 119 pb por unidad, cerca de los 123 plantados (la diferencia es ruido del mercado: el error típico es de 3 pb). Con la fecha de Nueva York cae a la mitad, 61 pb, aunque se compare sobre las mismas noticias (117 pb con la sesión correcta). La fecha UTC (68 pb) no arregla nada: a las 20:00 de Nueva York ya es el día siguiente en UTC, así que acierta con las noticias de noche y falla con las de la tarde.
¿De qué franjas sale el error? Las dos alineaciones, franja a franja:
def por_franja(g):
ok = g["y_fecha_ny"].notna()
fila = {"noticias": len(g),
"correcta": pendiente_pb(g["sentimiento_verdadero"],
g["y_reaccion"])[0]}
fila["fecha_ny"] = (pendiente_pb(g.loc[ok, "sentimiento_verdadero"],
g.loc[ok, "y_fecha_ny"])[0]
if ok.sum() > 30 else np.nan)
return pd.Series(fila)
orig.groupby("franja").apply(por_franja, include_groups=False) noticias correcta fecha_ny
franja
antes de abrir 6304.0 119.0 119.0
en sesión 9169.0 115.1 59.0
noche 1000.0 120.6 12.4
sin sesión 1978.0 134.0 NaN
tras el cierre 4067.0 119.2 -10.3Antes de la apertura las dos coinciden: la fecha de la noticia es la sesión de reacción. Tras el cierre, la fecha da
una pendiente negativa y sin sentido: compara cada noticia con el rendimiento de un día en el que aún no se conocía.
En sesión, la fecha recoge solo la parte del efecto que llega antes del cierre, la mitad. Las noticias de días sin
sesión no tienen rendimiento de su fecha y una alineación por fecha las pierde (o, peor, las asigna a la sesión
anterior con un ffill).
La figura de la cabecera resume las tres alineaciones. Es un sesgo hacia cero, no hacia arriba: quien alinea por fecha concluye que el texto «funciona menos», descarta señales buenas o subestima la capacidad de una estrategia. El sesgo peligroso (hacia arriba) aparece con los documentos que describen el precio.
Dos trampas del corpus: copias y crónicas
Las 1.209 crónicas de mercado del corpus se escriben tras el cierre a partir del movimiento del día («X se desploma un 8 %»). Su tono resume el rendimiento que acaba de ocurrir:
cr = df[(df["tipo_noticia"] == "mercado") & (df["k"] < N - 6)].copy()
ny_cr = cr["ts"].dt.tz_convert("America/New_York")
mismo_dia = por_fecha(cr, ny_cr.dt.tz_localize(None).dt.normalize())
siguiente = anormal[cr["k"], cr["j"]]
s_cr = cr["sentimiento_verdadero"]
print("con la fecha de la crónica:",
round(np.corrcoef(s_cr, mismo_dia)[0, 1], 3))
print("con la sesión de reacción: ",
round(np.corrcoef(s_cr, siguiente)[0, 1], 3))con la fecha de la crónica: 0.933
con la sesión de reacción: 0.047Con la fecha, una correlación de 0,93: la «señal» perfecta. Con la sesión en la que se podría operar, nada. Es causalidad inversa, y en un corpus real es frecuente: crónicas de cierre, resúmenes semanales, notas de analistas que explican un movimiento. Un filtro por tipo de noticia, o la alineación correcta, la elimina.
Las 12.266 copias sindicadas repiten una historia en otra agencia, de 2 minutos a 6 horas después. Contarlas como eventos:
copias = df[df["tipo_noticia"].isin(["original", "sindicada"])
& (df["k"] >= 6) & (df["k"] < N - 12)].copy()
copias["y_reaccion"] = dia_reaccion(copias)
print("solo originales:", len(orig),
pendiente_pb(orig["sentimiento_verdadero"], orig["y_reaccion"]))
print("con copias: ", len(copias),
pendiente_pb(copias["sentimiento_verdadero"],
copias["y_reaccion"]))solo originales: 22518 (118.8, 3.0)
con copias: 34743 (118.4, 2.4)La pendiente apenas se mueve (118,4 frente a 118,8: las copias heredan el sentimiento y casi siempre la sesión de la original), pero el número de eventos crece un 54 % y el error típico baja de 3,0 a 2,4 pb como si hubiera más información. No la hay: el estadístico t está inflado. Peor aún en una estrategia: tres copias de la misma noticia serían tres señales y la posición se triplicaría. Se agrupa por historia antes de nada (la lección 6.1.2 enseña a detectar copias sin el archivo de verdad) y, si dos noticias distintas de la misma empresa caen en la misma sesión, se agregan.
CAR por tono de diccionario
Con la alineación resuelta, la señal de texto es la de la lección 6.1.1: el tono del léxico financiero del curso, . Las noticias con tono distinto de cero se reparten en terciles y la rentabilidad anormal se acumula en tiempo de evento, de la sesión −5 a la +10 respecto a la sesión de reacción:
lex = pd.read_csv("texto6_lexico.csv").query("lista == 'financiera'")
listas = {i: (set(g.loc[g["categoria"] == "positiva", "palabra"]),
set(g.loc[g["categoria"] == "negativa", "palabra"]))
for i, g in lex.groupby("idioma")}
def tono(texto, idioma):
pos, neg = listas[idioma]
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)
orig["texto"] = orig["titular"] + ". " + orig["cuerpo"]
orig["tono"] = [tono(t, i) for t, i in
zip(orig["texto"], orig["idioma"])]
con_tono = orig[orig["tono"] != 0]
tercil = pd.qcut(con_tono["tono"], 3,
labels=["negativo", "medio", "positivo"])
dias = np.arange(-5, 11)
k, j = con_tono["k"].to_numpy(), con_tono["j"].to_numpy()
ar = pd.DataFrame(anormal[k[:, None] + dias, j[:, None]],
columns=dias)
ar_t = ar.groupby(tercil.to_numpy(), observed=True).mean()
car = 100 * ar_t.cumsum(axis=1)
resumen = pd.DataFrame({
"noticias": tercil.value_counts(),
"antes_-5_-1": 100 * ar_t.loc[:, -5:-1].sum(axis=1),
"dia_0": 100 * ar_t[0],
"deriva_1_10": 100 * ar_t.loc[:, 1:10].sum(axis=1),
})
resumen.round(2) noticias antes_-5_-1 dia_0 deriva_1_10
medio 3014 0.10 0.20 0.16
negativo 6451 0.04 -0.36 -0.19
positivo 3913 -0.01 0.44 0.30El tercil positivo sube un 0,44 % el día de reacción y un 0,30 % más en las diez sesiones siguientes; el negativo cae un 0,36 % y un 0,19 % más. El tercil medio también sube (0,20 % el día 0): el tono toma pocos valores (con una sola palabra de tono vale ±0,5) y los empates dejan los terciles desiguales; el del centro reúne sobre todo noticias con tono +0,5, de tono medio +0,37 y sentimiento verdadero medio +0,23. Las cinco sesiones previas son el control: entre −0,01 % y 0,10 % en los tres terciles, sin el patrón de una filtración ni de una alineación mal hecha. La deriva de los días +1 en adelante incluye el resto del efecto de las noticias publicadas en sesión (que se completa en el día +1) y la deriva plantada.
Como en 2.3.5, una CAR media no es una estrategia: falta comprobar que la deriva sobrevive a los costes (es de décimas de punto en diez sesiones), a la capacidad de las empresas pequeñas, donde el efecto es mayor (ejercicio 1), y a la latencia: la reacción del día 0 de una noticia de las 7:00 solo se captura comprando en la apertura, y la de una noticia en sesión, en segundos.
Topic modeling: de qué hablan las noticias
Un modelo de temas agrupa documentos por su vocabulario sin etiquetas. LDA (latent Dirichlet allocation, Blei, Ng y Jordan, 2003) supone que cada documento es una mezcla de temas y cada tema una distribución sobre palabras, y estima las dos cosas a partir de la matriz de recuentos documento-palabra. En un estudio de eventos sirve para dos cosas: saber de qué tipo es cada noticia cuando el proveedor no lo dice, y separar los temas que mueven el precio de los rutinarios.
Con las noticias en español, sin las palabras vacías ni los nombres de las empresas (si no, cada empresa sería un tema), 20 temas y una muestra de 8.000 noticias para ajustar (el ajuste por lotes en línea tarda unos segundos; después se asigna tema a todas):
from sklearn.decomposition import LatentDirichletAllocation
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.metrics import normalized_mutual_info_score
es = orig[orig["idioma"] == "es"].copy()
empresas = pd.read_csv("acciones6_empresas.csv")
nombres = {w for x in empresas["nombre"]
for w in re.findall(r"[a-záéíóúüñ]+", x.lower())}
vacias = set("""de la el en los las del un una que con por se su sus
al lo y a es para ha como más este esta sobre tras ante sin según
hasta entre dólares millones acciones sesión última cerraron
compañía grupo empresa""".split()) | nombres
vec = CountVectorizer(stop_words=sorted(vacias), min_df=15,
max_df=0.3,
token_pattern=r"(?u)\b[^\W\d_]{3,}\b")
muestra = es.sample(8000, random_state=0)
lda = LatentDirichletAllocation(n_components=20, random_state=0,
learning_method="online",
batch_size=1024, max_iter=10)
lda.fit(vec.fit_transform(muestra["texto"]))
es["tema"] = lda.transform(vec.transform(es["texto"])).argmax(axis=1)
vocab = vec.get_feature_names_out()
print("vocabulario:", len(vocab))
print("información mutua normalizada con el evento:",
round(normalized_mutual_info_score(es["evento"], es["tema"]), 3))vocabulario: 341
información mutua normalizada con el evento: 0.825La información mutua normalizada mide, entre 0 y 1, cuánto se parecen dos particiones: aquí, los temas que LDA encuentra sin etiquetas y los 20 tipos de evento con los que el generador escribió las noticias. Un valor de 0,8 es un acuerdo alto: LDA recupera casi todos los tipos de evento. Con textos de plantilla es más fácil que con noticias reales, en las que un tema mezcla vocabularios y el número de temas es una elección discutible. Las palabras más probables de algunos temas, con el evento verdadero más frecuente:
def resumen_tema(t):
g = es[es["tema"] == t]
pal = vocab[np.argsort(lda.components_[t])[::-1][:5]]
ev = g["evento"].value_counts()
return pd.Series({"noticias": len(g), "palabras": " ".join(pal),
"evento": ev.index[0],
"pureza": round(ev.iloc[0] / len(g), 2)})
temas = pd.DataFrame({t: resumen_tema(t) for t in range(20)}).T
temas.sort_values("noticias", ascending=False).head(8) noticias palabras evento pureza
8 1813 contrato próximos años firmado usd contrato 0.8
15 1606 junta accionistas general dentro celebrará junta 0.72
13 1453 precio objetivo ficticios valores recomendación analista_baja 0.58
4 1418 inversores foro presentará conferencia sector conferencia 1.0
3 1334 plataforma lanza impulso fuentes favorable producto 0.88
5 951 inversión banco sintético precio objetivo analista_baja 0.52
6 927 director nuevo nombra responsable jubila nombramiento 1.0
17 924 mayor pedido reciente adjudicado historia contrato 0.95¿Mueven el precio igual todos los temas? Una medida directa, que es la de una estrategia que compra con tono positivo y vende con tono negativo: la rentabilidad anormal media del día de reacción en la dirección del tono, , en pb por noticia. Tema a tema, frente a la materialidad media que el generador dio a sus noticias (0 para juntas, conferencias y nombramientos; hasta 2 para una oferta de compra):
def por_tema(g):
signo = np.sign(g["tono"])
return pd.Series({
"noticias": len(g),
"con_tono": (signo != 0).mean(),
"pb_en_direccion": 1e4 * (g["y_reaccion"] * signo).mean(),
"materialidad": g["materialidad"].mean(),
})
pt = es.groupby("tema").apply(por_tema, include_groups=False)
pt["evento"] = temas["evento"]
corr = pt["pb_en_direccion"].corr(pt["materialidad"])
print("correlación:", round(corr, 2))
pt.sort_values("pb_en_direccion").round(2).iloc[[0, 1, 2, -3, -2, -1]]correlación: 0.86
noticias con_tono pb_en_direccion materialidad evento
tema
4 1418.0 0.21 -0.53 0.00 conferencia
15 1606.0 0.21 -0.51 0.00 junta
6 927.0 0.19 2.77 0.00 nombramiento
1 461.0 0.69 53.91 0.80 dimision
0 437.0 0.77 75.06 0.99 retirada_producto
16 661.0 0.89 80.71 1.18 previsionesLos temas rutinarios (conferencias, juntas, nombramientos) no mueven el precio en la dirección de su tono (entre −0,5 y 2,8 pb por noticia) aunque una de cada cinco de sus noticias tenga tono: su texto lleva frases de tono, pero el evento no es material. Los temas de previsiones, retiradas de producto o dimisiones dan entre 54 y 81 pb por noticia. Entre los 20 temas, la correlación con la materialidad verdadera es de 0,86. Una señal de texto mejora si se pondera por tema o se filtran los rutinarios. Con unos cientos de noticias por tema, cada cifra tiene un error típico de varios pb: el orden importa más que cada valor.
BERTopic: temas con embeddings
LDA trabaja con recuentos de palabras. BERTopic (Grootendorst, 2022) parte de embeddings de frase (los de la lección 6.1.1), reduce su dimensión con UMAP, agrupa con HDBSCAN y describe cada grupo con las palabras más características de su clase (c-TF-IDF)1Nota 1 · Grootendorst (2022), «BERTopic: Neural topic modeling with a class-based TF-IDF procedure» (https://arxiv.org/abs/2203.05794); documentación (https://maartengr.github.io/BERTopic/, consultada el 2026-10-23): «the main steps for topic modeling with BERTopic are sentence-transformers, UMAP, HDBSCAN, and c-TF-IDF run in sequence».:
Fuente Mermaid
flowchart LR E[Embeddings] --> U[UMAP] U --> H[HDBSCAN] H --> C[c-TF-IDF]
A diferencia de LDA, asigna un único tema por documento, decide el número de temas (HDBSCAN deja ruido sin tema) y
capta sinónimos que no comparten palabras. Está instalado en el entorno ia del curso, no en el de esta lección; el
código, con las semillas que fijan el resultado:
from bertopic import BERTopic
from hdbscan import HDBSCAN
from sentence_transformers import SentenceTransformer
from umap import UMAP
textos = es["texto"].tolist()
st = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2",
device="cpu", local_files_only=True)
emb = st.encode(textos, normalize_embeddings=True,
show_progress_bar=False)
modelo = BERTopic(
umap_model=UMAP(n_neighbors=15, n_components=5, random_state=0),
hdbscan_model=HDBSCAN(min_cluster_size=50),
vectorizer_model=CountVectorizer(stop_words=sorted(vacias)),
)
temas_bt, _ = modelo.fit_transform(textos, emb)
modelo.get_topic_info().head(10)Ejecutado en el entorno ia sobre una muestra de 2.000 noticias en español (con las palabras vacías mínimas, sin
quitar los nombres de empresa), el resultado es instructivo: BERTopic con MiniLM agrupa sobre todo por empresa y
sector (los temas se llaman como las compañías: petroleum, steel, pharmaceuticals) y deja un tercio de las
noticias sin tema; su información mutua con el tipo de evento es de 0,10, frente a 0,82 de LDA. MiniLM es un modelo
inglés (lección 6.1.1): con texto español se fija en lo que entiende, los nombres propios. Para noticias en español
hace falta un modelo de embeddings multilingüe, quitar los nombres de empresa antes de agrupar y comprobar los temas
contra una muestra etiquetada, como aquí contra evento.
Una señal de documentos: cambios en los riesgos del 10-K
El texto no solo llega en noticias. Cohen, Malloy y Nguyen (2020) mostraron que las empresas que cambian mucho el lenguaje de sus informes anuales rinden después menos que las que no lo cambian (una cartera larga en las que no cambian y corta en las que cambian obtiene, según el resumen del artículo, hasta 188 pb de alfa al mes), y que el mercado no reacciona el día de la presentación: la información entra despacio2Nota 2 · Cohen, Malloy y Nguyen (2020), «Lazy Prices», Journal of Finance 75(3), 1371-1415 (https://doi.org/10.1111/jofi.12885): «a portfolio that shorts "changers" and buys "nonchangers" earns up to 188 basis points per month in alpha (over 22% per year) in the future», sin efecto en el anuncio..
Los 10-K sintéticos del curso llevan una sección Item 1A. Risk Factors. En el 18 % de los 10-K desde el ejercicio de 2019, la sección cambia mucho (entran riesgos de deterioro y las frases se agravan) y el generador planta una deriva del −3 % repartida en las 60 sesiones siguientes. La señal es la similitud coseno entre la bolsa de palabras de la sección y la del 10-K anterior de la misma empresa:
from sklearn.preprocessing import normalize
docs = pd.read_parquet("filings6_documentos.parquet")
k10 = docs[docs["tipo"] == "10-K"].sort_values(["emisor", "ts"]).copy()
def riesgos(html):
m = re.search(r"<h2>Item 1A\. Risk Factors</h2>(.*?)<h2>", html,
re.S)
return re.sub(r"<[^>]+>", " ", m.group(1)) if m else ""
k10["riesgos"] = k10["texto"].map(riesgos)
X = normalize(CountVectorizer().fit_transform(k10["riesgos"]))
misma = (k10["emisor"].to_numpy()[1:] == k10["emisor"].to_numpy()[:-1])
sim = np.asarray(X[1:].multiply(X[:-1]).sum(axis=1)).ravel()
k10["similitud"] = np.r_[np.nan, np.where(misma, sim, np.nan)]
k10 = k10.dropna(subset=["similitud"])
k10["año"] = k10["ts"].dt.year
k10.groupby("año")["similitud"].describe()[["count", "50%", "min"]] count 50% min
año
2020 60.0 1.000000 0.924000
2021 60.0 0.985860 0.915507
2022 60.0 1.000000 0.946799
2023 60.0 0.999342 0.933138
2024 60.0 1.000000 0.941738
2025 60.0 0.999829 0.952615La similitud mediana es casi 1: de un año a otro, la mayoría de las empresas copia su sección de riesgos con retoques. El mínimo de cada año es la señal. Con quintiles por año (para que un cambio común a todas, como el riesgo de la pandemia que entra en 2021, no se lleve el quintil), la CAR de las 60 sesiones que empiezan en la sesión de reacción. Los 10-K se presentan tras el cierre, así que esa sesión es la siguiente a la presentación:
riesgos_v = pd.read_csv("filings6_riesgos_verdad.csv")
cols = ["id_doc", "ticker_actual", "cambio_riesgos"]
k10 = k10.merge(riesgos_v[cols], on="id_doc")
k10["k"], _ = sesion_de(k10["ts"])
k10["j"] = k10["ticker_actual"].map(columna)
k10 = k10[k10["k"] + 60 <= N].copy()
k10["quintil"] = k10.groupby("año")["similitud"].transform(
lambda x: pd.qcut(x, 5, labels=False))
k10["cambia"] = k10["quintil"] == 0
ventana = np.arange(60)
ar60 = anormal[k10["k"].to_numpy()[:, None] + ventana,
k10["j"].to_numpy()[:, None]]
k10["car60"] = 100 * ar60.sum(axis=1)
print(pd.crosstab(k10["cambia"], k10["cambio_riesgos"]))
global_q = pd.qcut(k10["similitud"], 5, labels=False) == 0
print("cambios en el quintil sin agrupar por año:",
int((global_q & k10["cambio_riesgos"]).sum()))
k10.groupby("cambia")["car60"].agg(["count", "mean", "sem"]).round(2)cambio_riesgos False True
cambia
False 276 12
True 18 54
cambios en el quintil sin agrupar por año: 47
count mean sem
cambia
False 288 0.33 0.87
True 72 -3.88 1.88El quintil de menor similitud de cada año recoge 54 de los 66 cambios plantados (con quintiles de toda la muestra, 47) y su CAR a 60 sesiones es del −3,88 %, frente al +0,33 % del resto: el método recupera el efecto de −3 %, con un error típico de 1,9 puntos. Con 72 eventos en el quintil, el intervalo es amplio. La deriva es lenta, sin salto el día de la presentación: como en el artículo, el mercado del curso no lee los cambios de golpe.
Lo que cambia en real
- La hora del documento manda. Noticias, 8-K y comunicaciones a la CNMV llevan hora; un estudio diario las asigna a la primera sesión que cierra después. Si solo hay fecha, se asume lo peor: la sesión siguiente.
- Una historia, un evento. Copias, traducciones, correcciones y crónicas se agrupan o se excluyen antes de medir; si no, los errores típicos mienten.
- El control previo es obligatorio. Una CAR que se mueve antes del evento delata filtración, mala alineación o un texto que describe el pasado.
- La materialidad varía por tema. Una señal de texto agregada sin distinguir temas diluye las noticias que importan entre las rutinarias.
- Los efectos del curso están plantados. Las pendientes, CAR y similitudes de esta lección son la respuesta conocida de un generador. En datos reales, el tamaño y la duración del efecto hay que medirlos con estas mismas cautelas, y la literatura da resultados propios (Tetlock, 2007, encuentra que el pesimismo de una columna del Wall Street Journal anticipa presión a la baja seguida de una reversión)3Nota 3 · Tetlock (2007), Journal of Finance 62(3), 1139-1168 (https://doi.org/10.1111/j.1540-6261.2007.01232.x): el pesimismo de una columna del Wall Street Journal anticipa presión a la baja sobre los precios seguida de una reversión. Metodología de estudios de eventos: MacKinlay (1997), Journal of Economic Literature 35(1), 13-39.; ninguna cifra de esta lección se traslada a un mercado real.
Ejercicios
- El README del nivel 6 dice que el efecto de las noticias es mayor en las empresas pequeñas. Calcula la pendiente del día de reacción sobre el sentimiento verdadero en el tercio de empresas más pequeño y en el más grande por capitalización del primer día (cierre del 2018-01-02 por acciones en circulación).
Solución
acciones = empresas.set_index("ticker")["acciones_millones"]
capital = cierre.iloc[0] * acciones.reindex(cierre.columns)
tercio = pd.qcut(capital.rank(), 3, labels=["pequeñas", "medianas",
"grandes"])
orig["tamaño"] = orig["ticker_actual"].map(tercio)
orig.groupby("tamaño", observed=True).apply(
lambda g: pd.Series(pendiente_pb(g["sentimiento_verdadero"],
g["y_reaccion"]),
index=["pb", "error_tipico"]),
include_groups=False) pb error_tipico
tamaño
pequeñas 208.5 8.5
medianas 132.0 5.1
grandes 89.2 4.1Las pequeñas reaccionan más del doble por unidad de sentimiento que las grandes: el generador escala el efecto con la capitalización elevada a −0,25. En datos reales el patrón es parecido (menos cobertura de analistas, menos liquidez), y también los costes: una deriva mayor en las pequeñas no implica más beneficio neto.
- Mide la deriva de las sesiones +1 a +5 tras el día de reacción (sin contarlo) con el sentimiento verdadero y con el tono de diccionario. ¿Qué parte de la deriva recupera el diccionario?
Solución
k_d = orig["k"].to_numpy() + orig["en_sesion"].to_numpy()
j_d = orig["j"].to_numpy()
deriva = anormal[k_d[:, None] + np.arange(1, 6),
j_d[:, None]].sum(axis=1)
print("verdad: ", pendiente_pb(orig["sentimiento_verdadero"],
deriva))
print("diccionario: ", pendiente_pb(orig["tono"], deriva))verdad: (34.1, 5.7)
diccionario: (19.0, 5.2)Con el sentimiento verdadero, la pendiente de la deriva es de 34 pb (la plantada es de 43; la diferencia está a 1,5 errores típicos); con el diccionario, 19 pb, algo más de la mitad, porque el tono es una medida ruidosa del sentimiento (correlación de 0,71, lección 6.1.1) y el ruido en la variable explicativa atenúa la pendiente hacia cero. Una medida mejor del sentimiento (FinBERT en inglés) se traduce directamente en más señal.
- Un proveedor entrega noticias con una columna
fecha(sin hora) y otrahora_recepcionen la zona horaria de su servidor, sin decir cuál. ¿Cómo comprobarías con sus propios datos qué alineación es correcta?
Solución
Con un estudio de eventos de control. Primero, buscar la zona: la distribución de hora_recepcion debería tener
picos en horas conocidas (comunicados antes de la apertura, entre las 6:00 y las 9:30 de Nueva York, y tras el cierre,
a partir de las 16:00); si los picos caen a las 11:00 y a las 21:00, la hora está en UTC (o en la de Londres, según la
época del año). Segundo, con noticias de tono claro (o con notas de resultados, cuya sorpresa se conoce), calcular la
pendiente del rendimiento sobre el tono en la sesión de la fecha, en la anterior y en la siguiente, por franja
horaria: la alineación correcta concentra la reacción en una sola sesión y deja plano el control previo. Si la sesión
anterior a la fecha ya reacciona, la fecha es posterior a la publicación (o hay filtración). Ante la duda, retrasar.
Footnotes
-
Grootendorst (2022), «BERTopic: Neural topic modeling with a class-based TF-IDF procedure» (https://arxiv.org/abs/2203.05794); documentación (https://maartengr.github.io/BERTopic/, consultada el 2026-10-23): «the main steps for topic modeling with BERTopic are sentence-transformers, UMAP, HDBSCAN, and c-TF-IDF run in sequence». ↩
-
Cohen, Malloy y Nguyen (2020), «Lazy Prices», Journal of Finance 75(3), 1371-1415 (https://doi.org/10.1111/jofi.12885): «a portfolio that shorts "changers" and buys "nonchangers" earns up to 188 basis points per month in alpha (over 22% per year) in the future», sin efecto en el anuncio. ↩
-
Tetlock (2007), Journal of Finance 62(3), 1139-1168 (https://doi.org/10.1111/j.1540-6261.2007.01232.x): el pesimismo de una columna del Wall Street Journal anticipa presión a la baja sobre los precios seguida de una reversión. Metodología de estudios de eventos: MacKinlay (1997), Journal of Economic Literature 35(1), 13-39. ↩
Respuesta plegada en cada pregunta
Autoevaluación
Una noticia se publica a las 17:40 de Nueva York de un jueves que es sesión normal. ¿En qué rendimiento de cierre a cierre entra su efecto?
- AEn el del jueves, porque es la fecha de la noticia
- BEn el del viernes: el cierre del jueves ya había pasado cuando se publicó
- CEn el del miércoles
- DEn el del lunes siguiente
Ver respuesta
Respuesta B. En el del viernes: el cierre del jueves ya había pasado cuando se publicó
El rendimiento del jueves termina a las 16:00. Lo publicado después solo puede afectar al precio a partir de la sesión siguiente. En el corpus, la pendiente de las noticias tras el cierre con la fecha de la noticia es −10 pb por unidad de sentimiento: ruido puro.
Con la fecha de Nueva York de cada noticia, la pendiente del día de reacción sale 61 pb por unidad de sentimiento; con la sesión de reacción correcta, 117 pb en las mismas noticias. ¿De dónde sale casi toda la diferencia?
- ADe las noticias publicadas antes de la apertura
- BDe los fines de semana, que la fecha no tiene
- CDel redondeo de las horas
- DDe las noticias tras el cierre y de noche, cuyo efecto está en la sesión siguiente, y de las publicadas en sesión, cuyo efecto se reparte entre esa sesión y la siguiente
Ver respuesta
Respuesta D. De las noticias tras el cierre y de noche, cuyo efecto está en la sesión siguiente, y de las publicadas en sesión, cuyo efecto se reparte entre esa sesión y la siguiente
Antes de la apertura las dos alineaciones coinciden (119 pb). Tras el cierre, la fecha da −10 pb; de noche, 12; en sesión, 59 frente a 115, porque la parte del efecto que llega después del cierre cae en la sesión siguiente.
Las crónicas de mercado («X se desploma un 8 %», publicadas tras el cierre) tienen un tono que se correlaciona 0,93 con el rendimiento del mismo día. ¿Es una señal?
- ANo: el texto describe el movimiento que ya ocurrió; con la sesión correcta (la siguiente) la correlación es 0,05
- BSí, la mejor del corpus
- CSí, si se opera al cierre de ese día
- DSolo en las empresas pequeñas
Ver respuesta
Respuesta A. No: el texto describe el movimiento que ya ocurrió; con la sesión correcta (la siguiente) la correlación es 0,05
Es causalidad inversa: el precio escribe la noticia. Con la fecha como alineación parece una señal casi perfecta; con la sesión en la que se podría operar, desaparece.
Se repite el estudio contando también las 12.266 copias sindicadas de las noticias como eventos. La pendiente apenas cambia. ¿Cuál es el problema?
- ANinguno
- BQue las copias tienen otro sentimiento
- CQue el número de observaciones crece un 54 % sin información nueva: el error típico baja y el estadístico t se infla
- DQue las copias se publican antes que la original
Ver respuesta
Respuesta C. Que el número de observaciones crece un 54 % sin información nueva: el error típico baja y el estadístico t se infla
Una historia contada tres veces es una observación, no tres. Los errores típicos de MCO suponen observaciones independientes; con copias, sobreestiman la precisión. Primero se agrupa por historia (lección 6.1.2).
La similitud entre la sección de riesgos de un 10-K y la del año anterior baja en todas las empresas el año en que entra el riesgo de la pandemia. ¿Cómo se forma bien el quintil de «cambios»?
- ACon un umbral fijo de similitud para toda la muestra
- BPor año de presentación: el quintil de menor similitud de cada año, para que un cambio común a todas no domine la clasificación
- CSolo con las empresas grandes
- DCon la similitud con el 10-K del año siguiente
Ver respuesta
Respuesta B. Por año de presentación: el quintil de menor similitud de cada año, para que un cambio común a todas no domine la clasificación
Lo que predice el precio es cambiar más que las demás. Con quintiles por año, el quintil inferior recoge 54 de los 66 cambios plantados (frente a 47 con quintiles de toda la muestra) y su CAR a 60 sesiones es del −3,9 % frente al +0,3 % del resto. La similitud con el año siguiente sería información futura.
Ideas clave
- La unidad de un estudio de eventos con texto es el instante de publicación, no la fecha: antes de la apertura, la sesión de ese día; en sesión, esa sesión y lo que queda para la siguiente; tras el cierre, de noche o en un día sin sesión, la sesión siguiente. Las medias sesiones cierran a las 13:00.
- Alinear por fecha de Nueva York reduce la pendiente del día de reacción de 117 a 61 pb por unidad de sentimiento; por fecha UTC, a 68. No es un sesgo hacia arriba: esconde la señal, y con crónicas de mercado la invierte en causalidad inversa.
- Una historia se cuenta una vez: las copias sindicadas no cambian la pendiente, pero inflan el número de observaciones y el estadístico t.
- Con el tono de diccionario, la CAR por terciles separa reacción y deriva: el tercil positivo sube un 0,4 % el día 0 y sigue subiendo; el negativo cae. El control de las cinco sesiones previas debe ser plano.
- LDA recupera los tipos de evento sin etiquetas (información mutua normalizada de 0,82) y el tema separa noticias materiales de rutinarias; BERTopic hace lo mismo con embeddings en el entorno
ia. - La similitud entre secciones de riesgos de 10-K consecutivos, ordenada por año, señala los cambios plantados y una deriva negativa en las 60 sesiones siguientes; todos los efectos son sintéticos y plantados.
Recursos
Documentación
- MacKinlay (1997): Event Studies in Economics and Finance, Journal of Economic Literature 35(1)
- Tetlock (2007): Giving Content to Investor Sentiment, Journal of Finance 62(3)
- Cohen, Malloy y Nguyen (2020): Lazy Prices, Journal of Finance 75(3)
- Blei, Ng y Jordan (2003): Latent Dirichlet Allocation, JMLR 3
- scikit-learn: LatentDirichletAllocation
- BERTopic: documentación (embeddings, UMAP, HDBSCAN y c-TF-IDF)
- exchange_calendars: calendarios de bolsas con horas de apertura y cierre