---
title: "6.1.3 Event studies con texto y topic modeling"
description: "Alinear cada noticia con su sesión de reacción, medir el error de usar la fecha, CAR por tono, temas con LDA y BERTopic y cambios en los 10-K."
url: "https://pythonparatrading.com/curso-python-trading/nivel-6/event-studies-con-texto-y-topic-modeling/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-6/event-studies-con-texto-y-topic-modeling.png"
---

# 6.1.3 · 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.

- Curso: [Nivel 6: IA generativa, LLMs, agentes, big data y datos alternativos](https://pythonparatrading.com/curso-python-trading/nivel-6/)
- Módulo: 6.1 · NLP financiero clásico y moderno
- Duración: 75 min
- Requisitos: [0.4.4 · Ficheros (CSV, JSON, YAML, TOML) y fechas con zonas horarias](https://pythonparatrading.com/curso-python-trading/nivel-0/ficheros-pathlib-y-zonas-horarias/), [1.1.2 · Sesiones, subastas, festivos y calendarios](https://pythonparatrading.com/curso-python-trading/nivel-1/sesiones-subastas-y-calendarios-de-mercado/), [2.3.5 · Factores (value, calidad, baja volatilidad, tamaño) y estrategias de eventos](https://pythonparatrading.com/curso-python-trading/nivel-2/factores-y-estrategias-de-eventos/), [6.1.1 · Tokenización, embeddings, diccionarios financieros y FinBERT](https://pythonparatrading.com/curso-python-trading/nivel-6/tokenizacion-embeddings-loughran-mcdonald-y-finbert/)

## Al terminar esta lección

- Asignar a cada documento con hora su sesión de reacción (antes de la apertura, en sesión, tras el cierre, sin sesión, medias sesiones) con `exchange_calendars`.
- Medir el sesgo de la alineación por fecha frente a la alineación por instante y explicar de qué franjas horarias sale.
- Construir un estudio de eventos con señales de texto: CAR por terciles de tono, con controles previos y sin contar dos veces la misma historia.
- Extraer temas con LDA, comprobarlos contra los tipos de evento y usar el tema para separar noticias que mueven el precio de las que no.
- Medir una señal de documentos (similitud entre secciones de riesgos de 10-K consecutivos) con su ventana de deriva.

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

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

```python
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())
```
```output
(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 $t$ solo
puede entrar en los rendimientos de las sesiones cuyo cierre es posterior a $t$. 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:

```python
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"]
```
```output
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:00
```

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

```python
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)
```
```output
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: float64
```

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

<aside class="callout" data-kind="aviso">

En datos reales, la hora de una noticia puede ser la de publicación, la de recepción en el terminal del proveedor o
la de su base de datos, y algunos proveedores redondean al minuto o guardan la hora local sin zona. Antes de un
estudio de eventos hay que saber qué hora es (lección 1.4.2) y, si hay dudas, retrasar los eventos hasta la sesión
siguiente. El 8-K de EDGAR, por ejemplo, tiene una hora de aceptación y una fecha de presentación que pueden ser de
días distintos (lección 6.1.2).

</aside>

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

```python
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
```
```output
                    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.2
```

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

```python
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)
```
```output
                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.3
```

Antes 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`).

*Fig. — Pendiente del día de reacción por franja horaria de publicación (pb por unidad de sentimiento), con la sesión de reacción y con la fecha de Nueva York: tras el cierre, la fecha mide ruido.* Ocho barras en cuatro pares. Antes de abrir: 119 pb con las dos alineaciones. En sesión: 115 con la sesión y 59 con la fecha. Tras el cierre: 119 con la sesión y −10 con la fecha (destacada). De noche: 121 y 12.

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:

```python
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))
```
```output
con la fecha de la crónica: 0.933
con la sesión de reacción:  0.047
```

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

```python
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"]))
```
```output
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,
$(p - n)/(p + n + 1)$. 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:

```python
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)
```
```output
          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.30
```

*Fig. — Rentabilidad anormal acumulada alrededor de la sesión de reacción por tercil de tono de diccionario: salto el día 0, deriva después y control previo plano.* Tres curvas de CAR de la sesión −5 a la +10. Antes del evento, las tres se quedan cerca de cero. El día 0 el tercil positivo (destacado) salta a +0,43 % y termina en +0,73 %; el medio llega a +0,46 %; el negativo cae a −0,32 % y termina en −0,51 %.

El 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):

```python
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))
```
```output
vocabulario: 341
información mutua normalizada con el evento: 0.825
```

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

```python
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)
```
```output
   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,
$\overline{y \cdot \operatorname{signo}(\text{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):

```python
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]]
```
```output
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        previsiones
```

*Fig. — Veinte temas de LDA sobre las noticias en español: la reacción media en la dirección del tono crece con la materialidad que el generador dio a sus eventos (correlación 0,86).* Dispersión de 20 puntos. Los tres temas de materialidad cero (juntas, conferencias y nombramientos) quedan en torno a 0 pb por noticia. Los demás suben con la materialidad hasta el tema de previsiones (destacado), con materialidad 1,18 y 81 pb, y el de retiradas de producto, con 0,99 y 75 pb.

Los 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)[^1]:

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

```python skip
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 despacio[^2].

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:

```python
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"]]
```
```output
      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.952615
```

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

```python
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)
```
```output
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.88
```

*Fig. — Rentabilidad anormal acumulada tras la presentación de un 10-K: el quintil de menor similitud de su sección de riesgos con la del año anterior deriva a la baja durante 60 sesiones.* Dos curvas de CAR durante 60 sesiones. La del quintil de menor similitud (destacada, 72 informes) baja de forma gradual hasta −3,9 %; la del resto (288 informes) oscila cerca de cero y termina en +0,3 %.

El 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)[^3]; ninguna cifra de esta lección se
  traslada a un mercado real.

## Ejercicios

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

```python
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)
```
```output
             pb  error_tipico
tamaño
pequeñas  208.5           8.5
medianas  132.0           5.1
grandes    89.2           4.1
```

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

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

```python
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))
```
```output
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.

3. Un proveedor entrega noticias con una columna `fecha` (sin hora) y otra `hora_recepcion` en 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.

[^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».
[^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.
[^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.

## Autoevaluación

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

   - A. En el del jueves, porque es la fecha de la noticia
   - B. En el del viernes: el cierre del jueves ya había pasado cuando se publicó
   - C. En el del miércoles
   - D. En el del lunes siguiente

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

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

   - A. De las noticias publicadas antes de la apertura
   - B. De los fines de semana, que la fecha no tiene
   - C. Del redondeo de las horas
   - 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

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

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

   - A. No: el texto describe el movimiento que ya ocurrió; con la sesión correcta (la siguiente) la correlación es 0,05
   - B. Sí, la mejor del corpus
   - C. Sí, si se opera al cierre de ese día
   - D. Solo en las empresas pequeñas

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

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

   - A. Ninguno
   - B. Que las copias tienen otro sentimiento
   - 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
   - D. Que las copias se publican antes que la original

   *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).

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

   - A. Con un umbral fijo de similitud para toda la muestra
   - 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
   - C. Solo con las empresas grandes
   - D. Con la similitud con el 10-K del año siguiente

   *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)](https://ideas.repec.org/a/aea/jeclit/v35y1997i1p13-39.html) · ideas.repec.org
- [Tetlock (2007): Giving Content to Investor Sentiment, Journal of Finance 62(3)](https://doi.org/10.1111/j.1540-6261.2007.01232.x) · doi.org
- [Cohen, Malloy y Nguyen (2020): Lazy Prices, Journal of Finance 75(3)](https://doi.org/10.1111/jofi.12885) · doi.org
- [Blei, Ng y Jordan (2003): Latent Dirichlet Allocation, JMLR 3](https://www.jmlr.org/papers/v3/blei03a.html) · jmlr.org
- [scikit-learn: LatentDirichletAllocation](https://scikit-learn.org/stable/modules/generated/sklearn.decomposition.LatentDirichletAllocation.html) · scikit-learn.org
- [BERTopic: documentación (embeddings, UMAP, HDBSCAN y c-TF-IDF)](https://maartengr.github.io/BERTopic/) · maartengr.github.io
- [exchange_calendars: calendarios de bolsas con horas de apertura y cierre](https://github.com/gerrymanoim/exchange_calendars) · github.com

Lección anterior: [6.1.2 · Fuentes de texto: EDGAR, CNMV, transcripciones y redes sociales](https://pythonparatrading.com/curso-python-trading/nivel-6/fuentes-de-texto-edgar-cnmv-transcripciones-y-redes/)

Lección siguiente: [6.2.1 · Cómo funciona un LLM: del siguiente token a la API](https://pythonparatrading.com/curso-python-trading/nivel-6/como-funcionan-los-llm-apis-y-modelos-locales/)
