---
title: "6.7.5 Del dato alternativo a la señal"
description: "De descriptores de tarjeta a una señal operable: resolución de entidades, nowcasting de la sorpresa de ventas, backtest con costes y decaimiento del alfa."
url: "https://pythonparatrading.com/curso-python-trading/nivel-6/del-dato-alternativo-a-la-senal/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-6/del-dato-alternativo-a-la-senal.png"
---

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

- Curso: [Nivel 6: IA generativa, LLMs, agentes, big data y datos alternativos](https://pythonparatrading.com/curso-python-trading/nivel-6/)
- Módulo: 6.7 · Datos alternativos
- Duración: 70 min
- Requisitos: [2.4.7 · Modelado de costes: comisiones, horquilla, impacto, financiación y préstamo de títulos](https://pythonparatrading.com/curso-python-trading/nivel-2/modelado-de-costes/), [2.5.2 · In-sample y out-of-sample: walk-forward anclado y móvil](https://pythonparatrading.com/curso-python-trading/nivel-2/in-sample-out-of-sample-y-walk-forward/), [6.3.1 · Embeddings y bases vectoriales: FAISS, Chroma, pgvector y Qdrant](https://pythonparatrading.com/curso-python-trading/nivel-6/embeddings-y-bases-vectoriales/), [6.7.4 · Proveedores y due diligence de datos alternativos](https://pythonparatrading.com/curso-python-trading/nivel-6/proveedores-y-due-diligence-de-datos/)

## Al terminar esta lección

- Resolver descriptores de comercio a empresas comparando similitud de cadenas, n-gramas, embeddings y reglas de tokens, con el umbral elegido fuera de la muestra de evaluación.
- Unir datos alternativos a precios por un identificador permanente y el ticker vigente en cada fecha, sin perder la historia de las empresas que cambian de símbolo.
- Construir variables point-in-time para predecir la sorpresa de ventas y medir su R² con validación walk-forward frente al techo de los datos.
- Backtestear la señal alrededor de los resultados con costes y medir el decaimiento del alfa cuando el uso del dato se difunde.

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

```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\frac{\text{ventas}}{\text{consenso}} = d + e - \nu ,
$$

con $d$ el choque de demanda que miden los datos alternativos, $e$ un resto que no mide nadie y $\nu$ el error del
consenso. El día de resultados el precio reacciona a la parte de $u$ 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:

```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)}")
```
```output
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:

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

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

```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)
```
```output
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.* Cinco barras horizontales. difflib con umbral fijo de 0,75 acierta el 68,5 % de todos los descriptores; con el umbral ajustado en la otra mitad, el 77,6 %. Los n-gramas de caracteres aciertan el 87,9 % y los embeddings MiniLM el 86,2 %. Las reglas de tokens, destacadas, el 100 %.

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:

```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))
```
```output
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,

$$
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 $e_{t-4}$, que
entra en $x_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,

$$
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 $r_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.

```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}")
```
```output
x1: n = 675  R² en muestra = 0.278
x2: n = 575  R² en muestra = 0.366
```

En muestra, $x_1$ explica un 27,8 % de la varianza de la sorpresa (el valor del README) y $x_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.

```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}")
```
```output
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.* Cinco barras horizontales. El crecimiento interanual frente al consenso da un R² de 0,278 en muestra y 0,255 en walk-forward. El nivel frente a su historia da 0,366 en muestra y 0,370 en walk-forward, destacado. El techo, la parte de la sorpresa que miden los datos, es 0,566.

Fuera de muestra, $x_1$ baja al 25,5 % y $x_2$ se queda en el 37,0 %; juntas no mejoran a $x_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 $x_1$, disponible desde 2019; el ejercicio 2 la cambia por la
previsión walk-forward.

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

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

Un backtest de eventos trimestrales tiene pocas observaciones independientes. Antes de concluir nada, el error típico
del resultado y el número de variantes probadas (umbral, ventana, señal) importan tanto como la media: con varias
variantes, el Deflated Sharpe de la lección 2.5.4.

</aside>

## 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, $\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 ($\gamma(1-\varphi)$ con $\gamma$ = 1,2 % por
desviación de sorpresa):

```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}")
```
```output
      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:

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

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

2. Repite el backtest con la previsión walk-forward de $x_2$ (`T["prevision"]`) en lugar de $x_1$. ¿Cambian las
   conclusiones por periodo?

**Solución.**

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

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

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

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

   - A. Que hay que usar siempre el umbral más bajo
   - 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
   - C. Que `difflib` no sirve para nada
   - D. Que los descriptores sin relación son irrelevantes

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

   - A. Nada: los tickers son únicos
   - B. Se duplican las filas
   - C. Las observaciones anteriores al cambio no encuentran precio y la empresa desaparece de esa parte del backtest, en silencio
   - D. El precio se ajusta solo

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

   - A. Porque usa más datos futuros
   - B. Porque es un modelo más complejo
   - 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
   - D. Por azar

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

   - 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
   - B. Los datos de tarjetas empeoraron en 2022
   - C. El panel perdió tarjetas
   - D. El consenso dejó de publicarse

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

   - A. La estrategia funciona en los dos periodos
   - B. Hay que subir el apalancamiento después de 2022
   - C. Los costes no importan con resultados trimestrales
   - 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

   *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

### Research

- [Alpha factors y alpha trading en Python](https://pythonparatrading.com/alpha-factors-y-alpha-trading-en-python/) · Research-0007 · 5 min

### Documentación

- [Python: difflib (SequenceMatcher)](https://docs.python.org/3/library/difflib.html) · docs.python.org
- [Sentence Transformers: documentación](https://sbert.net/) · sbert.net

### Libros

- [McLean, R. D. y Pontiff, J. (2016): Does Academic Research Destroy Stock Return Predictability? Journal of Finance 71(1), 5-32. DOI 10.1111/jofi.12365](https://doi.org/10.1111/jofi.12365) · doi.org
- [Froot, K., Kang, N., Ozik, G. y Sadka, R. (2016): What Do Measures of Real-time Corporate Sales Tell Us about Earnings Surprises and Post-Announcement Returns? NBER WP 22366](https://www.nber.org/papers/w22366) · nber.org

### Datos

- [Datos del nivel 6: verdad de la resolución de comercios (CSV)](https://pythonparatrading.com/curso/datos/nivel-6/alt6_comercios_verdad.csv) · Descarga · alt6_comercios_verdad.csv
- [Datos del nivel 6: verdad trimestral de los datos alternativos (CSV)](https://pythonparatrading.com/curso/datos/nivel-6/alt6_verdad_trimestral.csv) · Descarga · alt6_verdad_trimestral.csv

Lección anterior: [6.7.4 · Proveedores y due diligence de datos alternativos](https://pythonparatrading.com/curso-python-trading/nivel-6/proveedores-y-due-diligence-de-datos/)

Lección siguiente: [6.7.6 · Satélite, visión por computador y cadena de suministro](https://pythonparatrading.com/curso-python-trading/nivel-6/satelite-vision-por-computador-y-cadena-de-suministro/)
