Lección 6.7.5 · 70 min

Del dato alternativo a la señal

Un fichero de transacciones no es una señal: hay que saber de qué empresa es cada comercio, qué ticker tenía en cada fecha, qué parte del gasto no esperaba el consenso y si el mercado ya lo descuenta. Esta lección recorre la cadena completa con los datos de tarjetas del curso y mide cada eslabón contra la verdad: la resolución de entidades pasa del 68,5 % al 100 %, la previsión de la sorpresa llega a un R² fuera de muestra de 0,37 y el alfa del día de resultados desaparece después de 2021.

Fig. — Correlación de la señal de tarjetas con el rendimiento anormal del día de resultados, por año (arriba), y efecto verdadero que queda ese día por desviación de sorpresa (abajo)
Lecciones del nivel 6
  1. 6.1 · NLP financiero clásico y moderno

    1. 6.1.1 Tokenización, embeddings, diccionarios financieros y FinBERT
    2. 6.1.2 Fuentes de texto: EDGAR, CNMV, transcripciones y redes sociales
    3. 6.1.3 Event studies con señales de texto, alineación temporal y topic modeling
  2. 6.2 · LLMs para investigación y trading

    1. 6.2.1 Cómo funciona un LLM: del siguiente token a la API
    2. 6.2.2 Prompting estructurado: JSON que se puede validar
    3. 6.2.3 Guidance, KPIs y tono: qué extrae bien un LLM
    4. 6.2.4 Fine-tuning ligero para tareas financieras: LoRA y QLoRA
    5. 6.2.5 Sesgo de anticipación en LLMs: el modelo que ya conoce el futuro
  3. 6.3 · RAG financiero

    1. 6.3.1 Embeddings y bases vectoriales: FAISS, Chroma, pgvector y Qdrant
    2. 6.3.2 Chunking de documentos financieros: tablas, notas al pie y XBRL
    3. 6.3.3 RAG sobre informes, folletos y KID, y cómo evaluarlo
  4. 6.4 · Agentes de IA para trading

    1. 6.4.1 Agentes con herramientas: tool calling, ReAct y planificación
    2. 6.4.2 Marcos de agentes y MCP
    3. 6.4.3 Arquitecturas multiagente tipo TradingAgents
    4. 6.4.4 Evidencia y límites de los agentes
    5. 6.4.5 Guardarraíles para agentes: el agente propone, el motor decide
    6. 6.4.6 Agentes de investigación: hipótesis, alpha mining y revisión
  5. 6.5 · IA como copiloto del quant

    1. 6.5.1 Asistentes de código con un flujo de tests primero
    2. 6.5.2 Revisión automática de backtests y migraciones de código
    3. 6.5.3 Riesgos del código generado por IA
  6. 6.6 · Big data para trading

    1. 6.6.1 Cuándo los datos son «big»: ticks, libros, opciones y universos
    2. 6.6.2 Parquet, Arrow, Polars en streaming y DuckDB fuera de memoria
    3. 6.6.3 Dask, Spark y Ray: de una máquina a un clúster
    4. 6.6.4 Bases de series temporales: TimescaleDB, QuestDB, ClickHouse, ArcticDB y kdb+
    5. 6.6.5 Almacenamiento en la nube, lakehouse (Delta, Iceberg) y GPU (RAPIDS, cuDF)
  7. 6.7 · Datos alternativos

    1. 6.7.1 Panorama y tamaño del mercado de datos alternativos
    2. 6.7.2 Tipos de datos alternativos: qué mide cada uno
    3. 6.7.3 Web scraping responsable
    4. 6.7.4 Proveedores y due diligence de datos alternativos
    5. 6.7.5 Del dato alternativo a la señal
    6. 6.7.6 Satélite, visión por computador y cadena de suministro

Cuatro eslabones

La lección 6.7.4 decidió que el dataset de tarjetas es usable con el archivo point-in-time y normalizado por tarjetas activas. Falta convertirlo en algo que se pueda operar. La cadena tiene cuatro eslabones y cada uno puede romperla:

Descriptor del comercioEmpresa: id permanenteTicker en cada fechaGasto trimestral PITSorpresa previstaPrecios y resultadosPosiciónP&L neto y su decaimiento
Fuente Mermaid
flowchart TB
  A[Descriptor del comercio] --> B[Empresa: id permanente]
  B --> C[Ticker en cada fecha]
  B --> D[Gasto trimestral PIT]
  D --> E[Sorpresa prevista]
  C --> F[Precios y resultados]
  E --> G[Posición]
  F --> G
  G --> H[P&L neto y su decaimiento]
  1. Resolución de entidades: a qué empresa pertenece cada texto del extracto bancario («SQ DUTNEX SERVICES», «TST OXOXGO»). Un error aquí mete el gasto de una clínica dental en las ventas de una agencia de viajes.
  2. Identificador y ticker: la empresa se guarda con un identificador que no cambia y se traduce al ticker de cada fecha para unirla con precios, noticias o resultados.
  3. Nowcasting: con el gasto del trimestre, antes de que la empresa publique, se estima su sorpresa de ventas frente al consenso.
  4. Backtest y decaimiento: se opera la sorpresa estimada alrededor de la publicación, con costes, y se mide si el resultado se mantiene en el tiempo.

Los datos son los del curso, con respuesta conocida: alt6_comercios_verdad.csv dice a qué empresa pertenece cada descriptor y alt6_verdad_trimestral.csv qué parte de la sorpresa miden de verdad los datos (pred) y cuánto efecto queda en el precio cada trimestre. El modelo del generador es sencillo de enunciar. La sorpresa de ventas es

u=log⁡ventasconsenso=d+e−ν,u = \log\frac{\text{ventas}}{\text{consenso}} = d + e - \nu ,

con dd el choque de demanda que miden los datos alternativos, ee un resto que no mide nadie y ν\nu el error del consenso. El día de resultados el precio reacciona a la parte de uu que el mercado no había descontado todavía. Son datos sintéticos: el efecto está plantado y sus cifras no dicen nada de los datos reales.

Resolución de entidades

Los descriptores de tarjeta son textos cortos, en mayúsculas y truncados a 22 caracteres, con prefijos de pasarelas de pago (SQ *, PAYPAL *, TST*, SP *), números de tienda y abreviaturas. Las empresas publican en su informe anual sus marcas y filiales (acciones6_filiales_marcas.csv), que son los nombres contra los que hay que comparar. La primera tarea es normalizar los dos lados igual:

IN [01]python
import re
import difflib
import numpy as np
import pandas as pd

verdad = pd.read_csv("alt6_comercios_verdad.csv").fillna(
    {"id_empresa": ""})
emp = pd.read_csv("acciones6_empresas.csv")
marcas = pd.read_csv("acciones6_filiales_marcas.csv")
PASARELAS = ("SQ *", "PAYPAL *", "TST* ", "SP * ")
RUIDO = r"\b(ONLINE|COM|STORE|APP|WWW|INC|CORP|SA)\b"

def limpiar(s):
    s = s.upper()
    for p in PASARELAS:
        s = s.replace(p, "")
    s = re.sub(r"[#*0-9.&]+", " ", s)      # números de tienda
    s = re.sub(RUIDO, " ", s)
    return re.sub(r"\s+", " ", s).strip()

nombres = pd.concat(
    [emp[["id_empresa"]].assign(nombre=emp["nombre_corto"]),
     marcas[["id_empresa", "nombre"]]], ignore_index=True)
nombres["clave"] = nombres["nombre"].map(limpiar)
desc = verdad["descriptor"].map(limpiar)
print(len(verdad), "descriptores ·", len(nombres),
      "nombres candidatos (120 empresas, sus marcas y filiales)")
for d in ["SQ *DUTNEX SERVICES", "BAGVO TEL #4581",
          "DUTNEX.COM*ONLINE", "BUPDOR DENTAL"]:
    print(f"{d:22s} -> {limpiar(d)}")
OUT [01]Normalizar descriptores y nombres
127 descriptores · 426 nombres candidatos (120 empresas, sus marcas y filiales)
SQ *DUTNEX SERVICES    -> DUTNEX SERVICES
BAGVO TEL #4581        -> BAGVO TEL
DUTNEX.COM*ONLINE      -> DUTNEX
BUPDOR DENTAL          -> BUPDOR DENTAL

El último ejemplo es la trampa del conjunto: 18 de los 127 descriptores son comercios sin relación con ninguna empresa del universo aunque compartan la raíz del nombre (una clínica dental, un bar). La línea base es la del README de los datos: el nombre candidato más parecido según difflib.SequenceMatcher, aceptado si la similitud supera 0,75. Para elegir el umbral sin hacer trampa, las empresas se reparten en dos mitades (las de número par sirven para ajustar; las impares, para evaluar), y los comercios sin relación se alternan:

IN [02]python
num = verdad["id_empresa"].str[1:].replace("", "0").astype(int)
ajuste = np.where(verdad["id_empresa"] == "",
                  np.arange(len(verdad)) % 2 == 0, num % 2 == 0)
ids = nombres["id_empresa"].to_numpy()

def acierto(pred, m):
    ok = np.asarray(pred) == verdad["id_empresa"].to_numpy()
    return ok[m].mean()

def asignar(S, u):
    return np.where(S.max(1) >= u, ids[S.argmax(1)], "")

def evaluar(nombre, S):
    umbrales = np.round(np.arange(0.30, 0.96, 0.05), 2)
    u = max(umbrales, key=lambda u: acierto(asignar(S, u), ajuste))
    p = asignar(S, u)
    sin = verdad["id_empresa"].to_numpy() == ""
    print(f"{nombre:9s} umbral {u:.2f} · ajuste "
          f"{acierto(p, ajuste):.1%}"
          f" · evaluación {acierto(p, ~ajuste):.1%}"
          f" · sin relación {acierto(p, sin):.0%}")
    return p

S_dif = np.array([[difflib.SequenceMatcher(None, d, c).ratio()
                   for c in nombres["clave"]] for d in desc])
print(f"difflib, umbral 0,75 fijo: "
      f"{acierto(asignar(S_dif, 0.75), np.ones(len(desc), bool)):.1%}")
p_dif = evaluar("difflib", S_dif)
OUT [02]Línea base con difflib y umbral elegido fuera de la evaluación
difflib, umbral 0,75 fijo: 68.5%
difflib   umbral 0.65 · ajuste 76.8% · evaluación 77.6% · sin relación 67%

Con el umbral fijo de 0,75 se reproduce el 68,5 % del README. Elegido en la mitad de ajuste, el umbral baja a 0,65 y el acierto en la mitad de evaluación sube al 77,6 %, a costa de atribuir a una empresa una parte de los comercios sin relación (acierta el 67 % de ellos, frente al 94 % con 0,75). La similitud de cadenas compara letras en orden y penaliza la diferencia de longitud: «DOKRA», lo que queda de «SP * DOKRA 617», se parece más a «NOKA», una marca de otra empresa (0,667), que a «DOKRA COMMUNICATIONS»; y «BUPDOR DENTAL», un comercio ajeno, puntúa 0,692 frente a «BUPDOR TRAVEL».

Dos alternativas habituales: n-gramas de caracteres con TF-IDF (similitud de coseno entre bolsas de trozos de 2-4 letras, robusta a abreviaturas) y embeddings de frases (lección 6.3.1), aquí all-MiniLM-L6-v2:

IN [03]python
from sklearn.feature_extraction.text import TfidfVectorizer
from sentence_transformers import SentenceTransformer

vec = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 4))
vec.fit(nombres["clave"])
S_tfidf = (vec.transform(desc)
           @ vec.transform(nombres["clave"]).T).toarray()
p_tfidf = evaluar("n-gramas", S_tfidf)

modelo = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2",
                             device="cpu", local_files_only=True)
def emb(textos):
    return modelo.encode(list(textos), normalize_embeddings=True,
                         show_progress_bar=False)
S_emb = emb(desc) @ emb(nombres["clave"]).T
p_emb = evaluar("MiniLM", S_emb)
OUT [03]N-gramas de caracteres y embeddings
n-gramas  umbral 0.65 · ajuste 88.4% · evaluación 87.9% · sin relación 22%
MiniLM    umbral 0.65 · ajuste 88.4% · evaluación 86.2% · sin relación 61%

Los dos pasan del 77 % al 86-88 % fuera de la muestra de ajuste, y los dos fallan sobre todo en lo mismo: los comercios sin relación, de los que los n-gramas solo aciertan el 22 % y MiniLM el 61 %. Un embedding de propósito general no sabe que en un extracto bancario «DENTAL» detrás del nombre de una agencia de viajes indica otro negocio; lo ve como una palabra más del contexto. Los nombres inventados («BAGVOR», «OXOXGO») tampoco tienen significado que el modelo pueda aprovechar: para códigos y marcas, los embeddings no son mejores que contar letras.

Lo que sí funciona en estos datos es una regla explícita sobre tokens: un descriptor pertenece a un nombre candidato si su primer token empieza el nombre y cada token restante (de 3 letras o más) es el comienzo de alguna palabra del nombre. «BAGVO TEL» encaja en «BAGVOR TELECOM»; «BAGVOR BAKERY», no, porque ninguna palabra de la empresa empieza por «BAKERY». Si encaja con varias empresas, o con ninguna, el descriptor no se asigna y va a revisión:

IN [04]python
def encaja(tokens, clave):
    palabras = clave.split()
    largos = [t for t in tokens if len(t) >= 3]
    return (bool(largos) and palabras[0].startswith(tokens[0])
            and all(any(w.startswith(t) for w in palabras)
                    for t in largos))

def reglas(d):
    exactos = set(nombres.loc[nombres["clave"] == d, "id_empresa"])
    if len(exactos) == 1:
        return exactos.pop()
    tokens = d.split()
    candidatos = {i for c, i in zip(nombres["clave"], ids)
                  if encaja(tokens, c)}
    return candidatos.pop() if len(candidatos) == 1 else ""

p_reglas = np.array([reglas(d) for d in desc])
sin = verdad["id_empresa"].to_numpy() == ""
print(f"reglas    ajuste {acierto(p_reglas, ajuste):.1%} · evaluación "
      f"{acierto(p_reglas, ~ajuste):.1%} · sin relación "
      f"{acierto(p_reglas, sin):.0%}")
resumen = pd.DataFrame({
    "verdad": verdad["relacion"],
    "difflib": p_dif == verdad["id_empresa"],
    "MiniLM": p_emb == verdad["id_empresa"],
    "reglas": p_reglas == verdad["id_empresa"]})
(100 * resumen.groupby("verdad").mean()).round(0)
OUT [04]Reglas de tokens con cola de revisión
reglas    ajuste 100.0% · evaluación 100.0% · sin relación 100%
              difflib  MiniLM  reglas
verdad
filial          100.0   100.0   100.0
marca           100.0   100.0   100.0
matriz           67.0    87.0   100.0
sin_relacion     67.0    61.0   100.0
Fig. — Acierto de la resolución de 127 descriptores de tarjeta a empresas (o a «sin relación») con cuatro métodos, en la mitad de evaluación

Las reglas aciertan todos los descriptores en las dos mitades. No hay que sobrevalorarlo: los descriptores sintéticos siguen unas pocas plantillas y las reglas están escritas mirándolas. En datos reales el orden de trabajo es el mismo (normalizar, generar candidatos con n-gramas o embeddings, verificar con reglas y con el código de categoría del comercio, MCC, cuando el proveedor lo da) y siempre queda una cola de revisión manual. El mapeo se versiona con fecha, porque las marcas se compran y se venden.

Del identificador al ticker de cada fecha

Resuelta la empresa, falta el ticker. Seis empresas del universo cambian de ticker entre 2019 y 2023; una de ellas tiene datos de tarjetas. Los precios del curso usan el ticker actual para toda la historia (como muchos proveedores), pero las noticias, las redes y cualquier dato que se haya archivado con el ticker de su fecha usan el antiguo:

IN [05]python
tickers = pd.read_csv("acciones6_tickers.csv",
                      parse_dates=["desde", "hasta"])
print(tickers[tickers["id_empresa"] == "E046"].to_string(index=False))

def ticker_en(ids_empresa, fechas):
    q = pd.DataFrame({"id_empresa": ids_empresa,
                      "fecha": pd.to_datetime(fechas)})
    q["_orden"] = np.arange(len(q))
    r = pd.merge_asof(q.sort_values("fecha"),
                      tickers.sort_values("desde"),
                      left_on="fecha", right_on="desde",
                      by="id_empresa")
    return r.sort_values("_orden")["ticker"].to_numpy()

precios = pd.read_parquet("acciones6_precios.parquet")
ejemplo = pd.DataFrame({"id_empresa": "E046",
                        "fecha": pd.to_datetime(["2020-06-30",
                                                 "2022-06-30"])})
ejemplo["ticker_fecha"] = ticker_en(ejemplo["id_empresa"],
                                    ejemplo["fecha"])
con_precio = ejemplo.merge(precios, left_on=["fecha", "ticker_fecha"],
                           right_on=["fecha", "ticker"], how="left")
print(con_precio[["fecha", "ticker_fecha", "cierre"]]
      .to_string(index=False))
OUT [05]El ticker vigente en cada fecha
id_empresa ticker      desde      hasta
      E046   GUFB 2018-01-02 2021-02-11
      E046   GUPE 2021-02-12        NaT
     fecha ticker_fecha   cierre
2020-06-30         GUFB      NaN
2022-06-30         GUPE 384.1148

Unir por la cadena del ticker de la fecha deja sin precio el trimestre de 2020: en los precios no existe GUFB. El error no da excepción; la fila se queda con NaN y la empresa desaparece del backtest antes del cambio. La regla de la lección 1.4.4 vale igual aquí: todo se guarda con el identificador permanente (id_empresa; en datos reales PERMNO, FIGI o el que dé el proveedor) y el ticker solo se usa para presentar o para unir con una fuente que no tenga otro. En lo que sigue, los precios se unen por id_empresa y su ticker actual, que es como están guardados.

Nowcasting de la sorpresa de ventas

Con el mapeo, el gasto se agrega por empresa y trimestre con el archivo point-in-time (lección 6.7.4) y se normaliza por tarjetas activas. El último día del trimestre llega tres días después de cerrarse y la empresa publica entre 17 y 62 días más tarde: la variable está disponible antes del resultado. El consenso de la víspera también es público antes, así que la comparación es legítima.

La primera variable es la de la lección 6.7.4: el crecimiento interanual del gasto por tarjeta frente al crecimiento que implica el consenso,

x1=Δ4log⁡gt−(log⁡const−log⁡ventast−4).x_1 = \Delta_4 \log g_t - \left(\log \text{cons}_t - \log \text{ventas}_{t-4}\right).

Tiene un defecto que se ve en la fórmula: las ventas de hace un año llevan su propio resto no medible et−4e_{t-4}, que entra en x1x_1 como ruido. La segunda variable evita la diferencia anual: compara el nivel del gasto con el del consenso y le resta la media de esa misma razón en los ocho trimestres anteriores,

x2=rt−18∑k=18rt−k,rt=log⁡gtconst.x_2 = r_t - \frac{1}{8}\sum_{k=1}^{8} r_{t-k}, \qquad r_t = \log\frac{g_t}{\text{cons}_t}.

La razón rtr_t tiene un nivel propio de cada empresa (la cuota de sus ventas que pasa por tarjeta, la cobertura del panel) que la media pasada elimina; solo usa trimestres anteriores, así que es point-in-time.

IN [06]python
pit = pd.read_parquet("alt6_tarjetas_pit.parquet")
panel = pd.read_csv("alt6_tarjetas_panel.csv", parse_dates=["fecha"])
mapa = pd.DataFrame({"descriptor": verdad["descriptor"],
                     "id_empresa": p_reglas})
mapa = mapa[mapa["id_empresa"] != ""]

def fin_trim(f):
    return f.dt.to_period("Q").dt.end_time.dt.normalize()

d = pit.merge(mapa, on="descriptor")
d["fin_trimestre"] = fin_trim(d["fecha"])
gasto = d.groupby(["id_empresa", "fin_trimestre"])["gasto_usd"].sum()
panel["fin_trimestre"] = fin_trim(panel["fecha"])
tarj = panel.groupby("fin_trimestre")["tarjetas_activas"].mean()

ventas = pd.read_csv("acciones6_ventas.csv",
                     parse_dates=["fin_trimestre", "fecha_publicacion"])
ventas = ventas.merge(emp[["ticker", "id_empresa"]], on="ticker")
T = (ventas.set_index(["id_empresa", "fin_trimestre"]).sort_index()
     .join(gasto, how="inner"))
T["g"] = T["gasto_usd"] / tarj.reindex(
    T.index.get_level_values(1)).to_numpy()
T["sorpresa"] = np.log(T["ventas"] / T["consenso_ventas"])
T["x1"] = (np.log(T["g"]).groupby(level=0).diff(4)
           - (np.log(T["consenso_ventas"])
              - np.log(T["ventas"].groupby(level=0).shift(4))))
T["r"] = np.log(T["g"] / T["consenso_ventas"])
T["x2"] = T["r"] - T.groupby(level=0)["r"].transform(
    lambda s: s.shift(1).rolling(8).mean())
T = T.reset_index()
T = T[T["fin_trimestre"] >= "2019-01-01"]
for c in ["x1", "x2"]:
    m = T.dropna(subset=[c])
    print(f"{c}: n = {len(m)}  R² en muestra = "
          f"{m[c].corr(m['sorpresa']) ** 2:.3f}")
OUT [06]Variables point-in-time de la sorpresa
x1: n = 675  R² en muestra = 0.278
x2: n = 575  R² en muestra = 0.366

En muestra, x1x_1 explica un 27,8 % de la varianza de la sorpresa (el valor del README) y x2x_2 un 36,6 %, con 100 empresa-trimestres menos porque necesita dos años de historia. El R² en muestra de una variable con una pendiente estimada es optimista. La prueba honesta es walk-forward (lección 2.5.2): para cada resultado, la pendiente se estima solo con los resultados publicados antes, y se mide el error de la predicción.

IN [07]python
T = T.dropna(subset=["x1", "x2"]).sort_values(
    "fecha_publicacion").reset_index(drop=True)

def walk_forward(cols, minimo=50):
    pred = np.full(len(T), np.nan)
    for k, fecha in enumerate(T["fecha_publicacion"]):
        pasado = T[T["fecha_publicacion"] < fecha]
        if len(pasado) < minimo:
            continue
        X = np.c_[np.ones(len(pasado)), pasado[cols]]
        b = np.linalg.lstsq(X, pasado["sorpresa"], rcond=None)[0]
        pred[k] = b[0] + T.loc[k, cols].to_numpy(float) @ b[1:]
    return pred

y = T["sorpresa"].to_numpy()
for cols in (["x1"], ["x2"], ["x1", "x2"]):
    p = walk_forward(cols)
    ok = ~np.isnan(p)
    r2 = 1 - ((y[ok] - p[ok]) ** 2).sum() / (
        (y[ok] - y[ok].mean()) ** 2).sum()
    print(f"{' + '.join(cols):8s} n = {ok.sum()}  "
          f"R² fuera de muestra = "
          f"{r2:.3f}")
T["prevision"] = walk_forward(["x2"])
verdad_t = pd.read_csv("alt6_verdad_trimestral.csv",
                       parse_dates=["fin_trimestre"])
verdad_t = verdad_t.merge(emp[["ticker", "id_empresa"]], on="ticker")
T = T.merge(verdad_t[["id_empresa", "fin_trimestre", "pred", "phi",
                      "sd_u"]], on=["id_empresa", "fin_trimestre"])
print(f"techo, misma muestra: R² = "
      f"{T['pred'].corr(T['sorpresa']) ** 2:.3f}")
OUT [07]Validación walk-forward de la previsión
x1       n = 525  R² fuera de muestra = 0.255
x2       n = 525  R² fuera de muestra = 0.370
x1 + x2  n = 525  R² fuera de muestra = 0.369
techo, misma muestra: R² = 0.566
Fig. — R² de la previsión de la sorpresa de ventas con dos variables point-in-time del gasto con tarjeta, en muestra y con validación walk-forward, frente al techo de los datos

Fuera de muestra, x1x_1 baja al 25,5 % y x2x_2 se queda en el 37,0 %; juntas no mejoran a x2x_2 sola. El techo, el R² de la sorpresa con la parte que miden los datos de verdad, es 0,566 en esta muestra: queda margen, que en estos datos procede del sesgo de composición del panel (lección 6.7.4) y del 6 % de operaciones que llegan tarde. La mejora de 0,255 a 0,370 no viene de un modelo más complejo, sino de una variable mejor pensada. Esa es la regla general del nowcasting con datos alternativos: el trabajo está en construir la medida comparable con la expectativa del mercado, no en el algoritmo.

Backtest alrededor de los resultados

La señal ya está; falta saber si gana dinero. Los resultados se publican antes de la apertura, así que la reacción es el rendimiento de la sesión de publicación, de la víspera al cierre de ese día. La estrategia más simple: comprar al cierre de la víspera si la sorpresa prevista es positiva y vender en corto si es negativa, y cerrar al cierre del día de resultados. El rendimiento se mide menos la media del mercado (el rendimiento medio de las 120 acciones), y la operación cuesta 20 pb ida y vuelta (comisión, horquilla e impacto de una acción de consumo; lección 2.4.7).

Para comparar con el README, la señal del backtest es x1x_1, disponible desde 2019; el ejercicio 2 la cambia por la previsión walk-forward.

IN [08]python
cierres = precios.pivot(index="fecha", columns="ticker",
                        values="cierre")
r = cierres.pct_change()
anormal = r.sub(r.mean(axis=1), axis=0)
actual = emp.set_index("id_empresa")["ticker"]
B = ventas.set_index(["id_empresa", "fin_trimestre"]).join(
    gasto, how="inner").sort_index()
B["g"] = B["gasto_usd"] / tarj.reindex(
    B.index.get_level_values(1)).to_numpy()
B["x1"] = (np.log(B["g"]).groupby(level=0).diff(4)
           - (np.log(B["consenso_ventas"])
              - np.log(B["ventas"].groupby(level=0).shift(4))))
B = B.reset_index().dropna(subset=["x1"])
B = B[B["fin_trimestre"] >= "2019-01-01"]
B = B.merge(verdad_t[["id_empresa", "fin_trimestre", "phi"]],
            on=["id_empresa", "fin_trimestre"])
B["anormal"] = [anormal.at[f, actual[i]] for f, i
                in zip(B["fecha_publicacion"], B["id_empresa"])]
COSTE = 0.0020
B["bruto"] = np.sign(B["x1"]) * B["anormal"]
B["neto"] = B["bruto"] - COSTE
B["año"] = B["fecha_publicacion"].dt.year
B["periodo"] = pd.cut(B["año"], [2018, 2021, 2022, 2025],
                      labels=["2019-2021", "2022", "2023-2025"])

def resumen(g):
    return pd.Series({
        "eventos": len(g),
        "bruto (pb)": 1e4 * g["bruto"].mean(),
        "error típico (pb)": 1e4 * g["bruto"].std() / np.sqrt(len(g)),
        "neto (pb)": 1e4 * g["neto"].mean(),
        "acierto (%)": 100 * (g["bruto"] > 0).mean()})

B.groupby("periodo", observed=True).apply(resumen).round(1)
OUT [08]Backtest de la señal alrededor de los resultados
           eventos  bruto (pb)  error típico (pb)  neto (pb)  acierto (%)
periodo
2019-2021    275.0        91.0               29.5       71.0         55.6
2022         100.0        46.3               51.0       26.3         53.0
2023-2025    300.0        -8.7               28.2      -28.7         52.3

Entre 2019 y 2021 la estrategia gana 91 pb brutos por evento con un error típico de 30 pb: unos tres errores típicos, 71 pb netos de costes. En 2022, 46 pb con un error de 51, que no se distinguen de cero. Entre 2023 y 2025, −9 pb brutos y −29 netos. El acierto direccional no pasa del 56 % en ningún periodo: la ganancia viene de que, cuando la señal acierta, el movimiento es mayor. Cada evento lleva un ruido de unos 5 puntos porcentuales (el salto de resultados del nivel 5 y la parte de la sorpresa que nadie mide), así que con cien eventos al año el error típico es de 50 pb: medir diferencias de decenas de pb exige muchos años de datos.

Decaimiento del alfa

La caída de la estrategia después de 2021 no es mala suerte. En el generador, el 2021-07-01 un estudio difunde el uso de los datos de tarjetas. Desde entonces una fracción creciente del mercado, φ=1−e−an˜os\varphi = 1 - e^{-\text{años}}, los usa: la información se incorpora al precio durante el trimestre, mientras se produce el gasto, y al día de resultados solo le queda la parte que los datos no miden. La correlación de la señal con el rendimiento del día de resultados, año a año, frente al efecto verdadero que le queda ese día (γ(1−φ)\gamma(1-\varphi) con γ\gamma = 1,2 % por desviación de sorpresa):

IN [09]python
anual = B.groupby("año").apply(lambda g: pd.Series({
    "eventos": len(g),
    "correlación": g["x1"].corr(g["anormal"]),
    "efecto verdadero (%)": 1.2 * (1 - g["phi"]).mean()}))
print(anual.round(2).to_string())
for a, b in [(2019, 2021), (2023, 2025)]:
    g = B[B["año"].between(a, b)]
    print(f"{a}-{b}: correlación {g['x1'].corr(g['anormal']):.2f}")
despues = T[T["fin_trimestre"] >= "2023-07-01"]
print(f"R² de la previsión tras 2023T2: "
      f"{despues['prevision'].corr(despues['sorpresa']) ** 2:.3f}")
OUT [09]Decaimiento del alfa año a año
      eventos  correlación  efecto verdadero (%)
año
2019     75.0         0.19                  1.20
2020    100.0         0.22                  1.20
2021    100.0         0.26                  1.08
2022    100.0        -0.01                  0.47
2023    100.0         0.01                  0.17
2024    100.0         0.11                  0.06
2025    100.0        -0.06                  0.02
2019-2021: correlación 0.22
2023-2025: correlación 0.03
R² de la previsión tras 2023T2: 0.451

Es la figura de la cabecera. La correlación es de 0,19-0,26 entre 2019 y 2021 y cae a valores alrededor de cero desde 2022 (entre −0,06 y 0,11), siguiendo al efecto verdadero (de 1,2 % a 0,02 %). Mientras tanto, la previsión de ventas no empeora: después de 2023 su R² es de 0,451, más alto que en toda la muestra. El dato sigue midiendo lo mismo; lo que se ha agotado es la ventaja de saberlo antes que los demás.

Es lo que documenta la literatura con anomalías publicadas: McLean y Pontiff (2016) estudian 97 variables que predecían rendimientos y encuentran que los rendimientos de sus carteras son un 26 % menores fuera de la muestra de los artículos y un 58 % menores después de su publicación, y lo atribuyen a que los inversores aprenden de las publicaciones. Con datos de ventas en tiempo real, Froot, Kang, Ozik y Sadka (2016) construyen medidas de ventas de minoristas a partir de unos 50 millones de dispositivos móviles y encuentran que explican el crecimiento de las ventas y las sorpresas de resultados. Para un dato alternativo, las preguntas que siguen son prácticas:

nosínosíSeñal medida por añosCae tras una fechaSeguir midiendoSigue prediciendo ventasDato roto: due diligenceAlfa difundidoOperar antes o retirar
Fuente Mermaid
flowchart TB
  A[Señal medida por años] --> B{Cae tras una fecha}
  B -->|no| C[Seguir midiendo]
  B -->|sí| D{Sigue prediciendo ventas}
  D -->|no| E[Dato roto: due diligence]
  D -->|sí| F[Alfa difundido]
  F --> G[Operar antes o retirar]

Si la señal deja de predecir las ventas, el problema es el dato (un cambio del panel, del mapeo o de la entrega: la lista de la lección 6.7.4). Si las sigue prediciendo pero el precio ya no reacciona el día de resultados, el alfa se ha difundido. Las salidas son operar antes (con el gasto semanal, durante el trimestre, cuando la información todavía no está en el precio; en estos datos, la deriva repartida en unas 60 sesiones es pequeña frente al ruido, como mide el ejercicio 3), combinarlo con otras fuentes o dejar de pagarlo. La decisión depende del coste del dato (lección 6.7.1), no solo de su correlación.

Ejercicios

  1. Las reglas de tokens rechazan un descriptor si encaja con varias empresas. Cuenta cuántos descriptores encajan con más de un candidato antes de aplicar la coincidencia exacta, y cuáles son.
Solución
IN [10]python
ambiguos = []
for v, d in zip(verdad["descriptor"], desc):
    c = {i for k, i in zip(nombres["clave"], ids)
         if encaja(d.split(), k)}
    if len(c) > 1:
        ambiguos.append((v, sorted(c)))
print(len(ambiguos))
for v, c in ambiguos:
    print(f"{v:22s} {c}")
OUT [10]Descriptores ambiguos para las reglas
4
SP * VELMIO            ['E005', 'E059', 'E076']
VELLU                  ['E041', 'E046', 'E075']
TST* NONO              ['E013', 'E070']
PAYPAL *LUOX           ['E074', 'E081']

Son marcas cortas que son el comienzo de otra marca de otra empresa. Sin la regla de coincidencia exacta irían a revisión manual; con ella se resuelven. En datos reales, la misma situación aparece con nombres genéricos («HOME», «FOODS»), y la solución es la misma: priorizar lo exacto y mandar a revisión lo demás.

  1. Repite el backtest con la previsión walk-forward de x2x_2 (T["prevision"]) en lugar de x1x_1. ¿Cambian las conclusiones por periodo?
Solución
IN [11]python
C = T.copy()
C["anormal"] = [anormal.at[f, actual[i]] for f, i
                in zip(C["fecha_publicacion"], C["id_empresa"])]
C = C.dropna(subset=["prevision"])
C["bruto"] = np.sign(C["prevision"]) * C["anormal"]
C["neto"] = C["bruto"] - COSTE
C["año"] = C["fecha_publicacion"].dt.year
C["periodo"] = pd.cut(C["año"], [2018, 2021, 2022, 2025],
                      labels=["2019-2021", "2022", "2023-2025"])
C.groupby("periodo", observed=True).apply(resumen).round(1)
OUT [11]Backtest con la previsión walk-forward
           eventos  bruto (pb)  error típico (pb)  neto (pb)  acierto (%)
periodo
2019-2021    125.0        -9.6               44.9      -29.6         48.0
2022         100.0        74.6               50.7       54.6         58.0
2023-2025    300.0        -1.0               28.2      -21.0         52.0

Con la previsión de x2x_2 el resultado cambia mucho: −9,6 pb (error típico 45) entre 2019 y 2021, +74,6 pb (51) en 2022 y −1,0 pb (28) entre 2023 y 2025. Ninguno se distingue de cero. La previsión predice mejor las ventas, pero empieza más tarde (necesita dos años de historia y 50 resultados para la primera pendiente): en el periodo anterior a la difusión solo quedan 125 eventos, y con un ruido de unos 5 puntos por evento el error típico es de 45 pb. Una previsión mejor de las ventas no se traduce de forma medible en más P&L con tan pocos eventos, y la variante elegida cambia el backtest más que su error típico: si se hubieran probado las dos y publicado la mejor, sería selección (lección 2.5.4). Lo robusto es la medida de la cabecera, la correlación por años, que no depende de un umbral de operación.

  1. ¿Qué parte del alfa se podría recuperar operando durante el trimestre? Usa la columna de verdad deriva_trimestre de alt6_verdad_trimestral.csv (el rendimiento total que la difusión reparte en las sesiones del trimestre) y compárala con el ruido de un rendimiento de 60 sesiones.
Solución
IN [12]python
vt = verdad_t[verdad_t["phi"] > 0]
vt = vt[vt["id_empresa"].isin(mapa["id_empresa"])]
deriva = vt["deriva_trimestre"].abs().mean()
ruido = anormal[actual[mapa["id_empresa"].unique()]].std().median()
print(f"deriva media por trimestre: {100 * deriva:.2f} %")
print(f"ruido de 60 sesiones: {100 * ruido * np.sqrt(60):.1f} %")
razon = deriva / (ruido * np.sqrt(60))
print(f"razón señal/ruido por evento: {razon:.3f}")
OUT [12]La deriva dentro del trimestre frente al ruido
deriva media por trimestre: 0.61 %
ruido de 60 sesiones: 12.0 %
razón señal/ruido por evento: 0.051

La deriva que la difusión reparte dentro del trimestre es pequeña frente al ruido de 60 sesiones de una acción individual. Con una razón señal/ruido tan baja por evento, harían falta miles de empresa-trimestres para medirla. En estos datos, quien llega tarde a un dato alternativo no tiene dónde ir: el alfa no se ha movido a otra ventana explotable, simplemente se ha repartido entre todos los que lo usan.

Respuesta plegada en cada pregunta

Autoevaluación

  1. La línea base difflib con umbral 0,75 acierta el 68,5 % de los 127 descriptores; con umbral 0,60 acierta más de los que sí son de una empresa pero solo un tercio de los «sin relación». ¿Qué enseña?

    • AQue hay que usar siempre el umbral más bajo
    • BQue el umbral reparte errores entre falsos positivos (comercios ajenos atribuidos a una empresa) y falsos negativos, y debe elegirse en una muestra distinta de la que se usa para evaluar
    • CQue difflib no sirve para nada
    • DQue los descriptores sin relación son irrelevantes
    Ver respuesta

    Respuesta B. Que el umbral reparte errores entre falsos positivos (comercios ajenos atribuidos a una empresa) y falsos negativos, y debe elegirse en una muestra distinta de la que se usa para evaluar

    «BUPDOR DENTAL» puntúa 0,69 frente a «BUPDOR TRAVEL», más que el descriptor «SP * BUPDOR 848» limpio (0,63), que sí es de la empresa. Bajar el umbral recupera descriptores cortos y atribuye clínicas dentales a una agencia de viajes. En la lección, el umbral se elige con la mitad de las empresas y se evalúa con la otra mitad.

  2. Los precios del curso usan el ticker actual para toda la historia y las noticias usan el vigente en su fecha. Una empresa cambió de GUFB a GUPE el 2021-02-12. ¿Qué pasa si se une el dato antiguo a los precios por la cadena del ticker?

    • ANada: los tickers son únicos
    • BSe duplican las filas
    • CLas observaciones anteriores al cambio no encuentran precio y la empresa desaparece de esa parte del backtest, en silencio
    • DEl precio se ajusta solo
    Ver respuesta

    Respuesta C. Las observaciones anteriores al cambio no encuentran precio y la empresa desaparece de esa parte del backtest, en silencio

    La unión se hace por un identificador permanente (id_empresa, en datos reales PERMNO, FIGI o similar) y el ticker se resuelve para cada fecha con la tabla de historia (lección 1.4.4).

  3. La señal simple (crecimiento del gasto por tarjeta menos el que implica el consenso) explica un 27,8 % de la varianza de la sorpresa en muestra y un 25,5 % fuera de muestra. Comparar el nivel del gasto con el consenso frente a su propia historia sube el R² fuera de muestra al 37 %. ¿Por qué mejora?

    • APorque usa más datos futuros
    • BPorque es un modelo más complejo
    • CPorque el crecimiento interanual arrastra el error no medible del mismo trimestre del año anterior; el nivel frente a su media pasada lo diluye
    • DPor azar
    Ver respuesta

    Respuesta C. Porque el crecimiento interanual arrastra el error no medible del mismo trimestre del año anterior; el nivel frente a su media pasada lo diluye

    El crecimiento del consenso se mide frente a las ventas de hace un año, que llevan su propio error e no medible. La variable de nivel solo usa trimestres pasados (point-in-time) y promedia ese ruido. El techo, con la parte medible verdadera, es 0,567.

  4. Entre 2019 y 2021 la señal tiene una correlación de 0,22 con el rendimiento del día de resultados; entre 2023 y 2025, de 0,03. ¿Qué explicación es coherente con los datos y con la literatura?

    • AMás inversores usan los datos de tarjetas desde que se difundió su uso, y el precio incorpora la información durante el trimestre: al día de resultados le queda poca sorpresa
    • BLos datos de tarjetas empeoraron en 2022
    • CEl panel perdió tarjetas
    • DEl consenso dejó de publicarse
    Ver respuesta

    Respuesta A. Más inversores usan los datos de tarjetas desde que se difundió su uso, y el precio incorpora la información durante el trimestre: al día de resultados le queda poca sorpresa

    La señal sigue prediciendo las ventas igual de bien (R² de 0,277 después de 2023). Lo que cambia es cuánto falta por descontar el día de resultados: el efecto verdadero cae de 1,16 % a 0,09 % por desviación de sorpresa. McLean y Pontiff (2016) miden la misma dinámica en 97 anomalías publicadas.

  5. Cada operación de la estrategia dura una sesión (de la víspera al cierre del día de resultados) y cuesta 20 pb ida y vuelta. Entre 2019 y 2021 gana 91 pb brutos por evento con un error típico de 30 pb; entre 2023 y 2025, −9 pb con un error de 28. ¿Qué conclusión es correcta?

    • ALa estrategia funciona en los dos periodos
    • BHay que subir el apalancamiento después de 2022
    • CLos costes no importan con resultados trimestrales
    • DEl primer periodo es significativo y cubre costes; en el segundo el bruto es cero y el neto, negativo: el alfa se ha ido
    Ver respuesta

    Respuesta D. El primer periodo es significativo y cubre costes; en el segundo el bruto es cero y el neto, negativo: el alfa se ha ido

    91 / 30 ≈ 3 errores típicos. Después de la difusión, el bruto no se distingue de cero y los 20 pb de costes lo dejan en pérdidas. Con un ruido de 5 % por evento, hacen falta cientos de eventos para ver diferencias de decenas de pb.

Ideas clave

  • La resolución de entidades es el primer filtro: con difflib y un umbral fijo se asigna bien el 68,5 % de los descriptores; con n-gramas o embeddings, un 86-88 % fuera de muestra; con reglas de tokens sobre nombres, marcas y filiales, el 100 % en estos datos sintéticos. En datos reales, las reglas se combinan con revisión manual.
  • El enlace con precios se hace por un identificador permanente y el ticker vigente en cada fecha: unir por la cadena del ticker pierde en silencio la historia de las empresas que cambian de símbolo.
  • Las variables point-in-time bien construidas importan más que el modelo: comparar el nivel del gasto con el consenso frente a su propia historia sube el R² fuera de muestra de 0,255 a 0,370, frente a un techo de 0,567.
  • Backtest alrededor de resultados, con costes: 91 pb brutos por evento entre 2019 y 2021 (t ≈ 3) y −9 pb entre 2023 y 2025. La señal sigue prediciendo las ventas; lo que desaparece es la parte que el precio no descontaba.
  • El alfa de un dato alternativo decae cuando su uso se difunde: el efecto verdadero del día de resultados pasa de 1,2 % a 0,02 % por desviación de sorpresa. Hay que medirlo por periodos, no en toda la muestra.

Recursos