Lección 5.2.2 · 65 min

Fundamentales point-in-time, macro y datos alternativos

Un dato contable tiene tres fechas: la del periodo que describe, la de su publicación y la de cada versión posterior. Esta lección convierte informes trimestrales en features semanales sin mirar el futuro, mide cuánto infla el IC alinearlos con el fin del trimestre, separa lo que cambian las reexpresiones y trata dos familias más: las variables macro, iguales para todas las acciones, y los datos alternativos, que solo existen para una parte del universo.

Fig. — Rendimiento acumulado menos el mercado alrededor de la publicación de resultados, por quintil del SUE (23.600 publicaciones): casi todo el movimiento ocurre el día de la publicación, unas 26 sesiones después del fin del trimestre
Lecciones del nivel 5
  1. 5.1 · Fundamentos de ML aplicados a finanzas

    1. 5.1.1 Por qué el machine learning financiero es distinto
    2. 5.1.2 Aprendizaje supervisado, no supervisado y por refuerzo; pipelines de scikit-learn
    3. 5.1.3 Métricas económicas para modelos de trading: más allá del acierto
  2. 5.2 · Ingeniería de características

    1. 5.2.1 Features de precio, volumen y microestructura
    2. 5.2.2 Fundamentales point-in-time, macro y datos alternativos
    3. 5.2.3 Diferenciación fraccional para features
    4. 5.2.4 Normalización sin leakage y rank transversal
    5. 5.2.5 Importancia de features: MDI, MDA, SFI y SHAP
  3. 5.3 · Etiquetado y muestreo

    1. 5.3.1 Etiquetado de horizonte fijo y sus problemas
    2. 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
    3. 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
    4. 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
    5. 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
  4. 5.4 · Validación para series financieras

    1. 5.4.1 Por qué falla el k-fold aleatorio en datos financieros
    2. 5.4.2 Purged k-fold con embargo y CPCV para modelos de ML
    3. 5.4.3 Walk-forward con reentrenamiento: ventanas y frecuencia
    4. 5.4.4 Backtest de señales de ML con costes y Deflated Sharpe
  5. 5.5 · Modelos clásicos

    1. 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
    2. 5.5.2 Árboles de decisión, Random Forest y bagging
    3. 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
    4. 5.5.4 Optuna y el riesgo de sobreajustar la validación
    5. 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
    6. 5.5.6 Learning to rank para la selección de acciones
  6. 5.6 · Deep learning con Keras 3

    1. 5.6.1 Keras 3: un modelo, varios motores de cálculo
    2. 5.6.2 Un MLP sobre un panel de acciones: regularizar y comparar
    3. 5.6.3 CNN 1D, LSTM y GRU: ventanas, escalado y fugas
    4. 5.6.4 Callbacks, una pérdida de Sharpe y el modelo en producción
  7. 5.7 · Deep learning con PyTorch

    1. 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
    2. 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
    3. 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
    4. 5.7.4 Redes de grafos para relaciones entre activos
    5. 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
  8. 5.8 · Modelos fundacionales de series temporales

    1. 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
    2. 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
    3. 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
    4. 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
    5. 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
  9. 5.9 · Aprendizaje por refuerzo

    1. 5.9.1 MDP, Q-learning, PPO y entornos Gymnasium
    2. 5.9.2 RL para ejecución, market making y cobertura dinámica
    3. 5.9.3 Por qué el RL sobreajusta en trading direccional
  10. 5.10 · Interpretabilidad, robustez y riesgo de modelo

    1. 5.10.1 SHAP, dependencia parcial y estabilidad de las importancias
    2. 5.10.2 Deriva de datos y de concepto: detectarla y qué hacer
    3. 5.10.3 Ensembles, combinación de señales y datos sintéticos

Tres fechas para cada dato

Un precio se conoce en el instante en que se negocia. Un dato contable no: describe un periodo que ya terminó, se publica semanas después y puede cambiar meses más tarde. Cada cifra tiene, por tanto, tres fechas:

FechaQué esEn el panelQué pasa si se usa mal
Fin del periodoEl trimestre que describe el informefin_trimestreLa feature conoce el resultado 3-9 semanas antes de publicarse
PublicaciónCuando el mercado conoce la cifrafecha_publicacionEs la buena para la primera versión
VersiónDesde cuándo se conoce cada versión (original o reexpresada)fecha_versionCon la última versión siempre, el pasado lleva correcciones que nadie tenía

La lección 1.5.1 enseña a leer estados financieros con esas fechas y a calcular el beneficio de los últimos cuatro trimestres (TTM) point-in-time; la 1.5.2 hace lo mismo con las series macro y sus vintages; la 2.3.5 mide la deriva tras resultados como estrategia de eventos. Esta lección no repite eso: construye las features de un panel completo para un modelo y mide lo que cuesta cada error en el IC, que es lo que vería un modelo de machine learning.

Fin del trimestrePublicaciónReexpresiónFeature en t
Fuente Mermaid
flowchart LR
  Q[Fin del trimestre] --> P[Publicación]
  P --> R[Reexpresión]
  P --> F[Feature en t]
  R --> F

Una feature solo puede usar las flechas que llegan a ella en la fecha tt: la publicación y las reexpresiones anteriores a tt, nunca el fin del trimestre por sí solo.

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

fu = pd.read_csv("panel5_fundamentales.csv", parse_dates=[
    "fin_trimestre", "fecha_publicacion", "fecha_version"])
print(fu["version"].value_counts().to_dict())
retraso = (fu["fecha_publicacion"] - fu["fin_trimestre"]).dt.days
print("días del fin del trimestre a la publicación:",
      retraso.describe()[["min", "50%", "max"]].to_dict())
fu[fu["ticker"] == "DOPE"].iloc[18:23, :6]
OUT [01]Informes, versiones y retrasos
{'original': 28400, 'reexpresado': 1925}
días del fin del trimestre a la publicación: {'min': 20.0, '50%': 38.0, 'max': 66.0}
     ticker fin_trimestre fecha_publicacion      version fecha_version  beneficio
7430   DOPE    2012-06-30        2012-08-06     original    2012-08-06       3.66
7658   DOPE    2012-09-30        2012-10-22     original    2012-10-22    -290.66
8400   DOPE    2012-12-31        2013-02-28     original    2013-02-28     -48.56
8483   DOPE    2012-03-31        2012-05-07  reexpresado    2013-03-11     -19.94
8612   DOPE    2013-03-31        2013-04-29     original    2013-04-29     108.45

Los 400 informes por trimestre del panel se publican entre 20 y 66 días después del cierre (38 de mediana), y 1.925 filas son reexpresiones: la misma cifra corregida, con su propia fecha_version. En el extracto de DOPE, el informe del primer trimestre de 2012 se reexpresa el 11 de marzo de 2013, casi un año después, y su fila aparece ordenada por esa fecha, junto a informes mucho más recientes. Ordenar por fecha_version es lo que permite reconstruir qué se sabía cada día.

El estado point-in-time de cada acción

El procedimiento tiene dos pasos. Primero, recorrer las versiones de cada acción en el orden en que se conocieron y guardar, después de cada una, el estado de lo que se sabía: aquí, el beneficio TTM con los cuatro últimos trimestres conocidos en su versión vigente. Después, unir cada fecha del panel con el último estado anterior o igual a ella (merge_asof por acción). Para validar el resultado, se recalcula la rentabilidad del beneficio (ey, beneficio TTM entre capitalización) y se compara con la publicada:

IN [02]python
def estado_pit(fu, fecha="fecha_version"):
    """Una fila por acción y fecha en que cambia lo que se sabe:
    beneficio de los 4 últimos trimestres conocidos (TTM)."""
    filas = []
    orden = fu.sort_values([fecha, "fin_trimestre"], kind="stable")
    for tk, g in orden.groupby("ticker", sort=False):
        conocido = {}  # fin_trimestre -> beneficio vigente
        for f, q, ben in zip(g[fecha], g["fin_trimestre"],
                             g["beneficio"]):
            conocido[q] = ben
            ultimos = sorted(conocido)[-4:]
            ttm = sum(conocido[k] for k in ultimos)
            filas.append((tk, f, ttm))
    st = pd.DataFrame(filas, columns=["ticker", "fecha", "ttm"])
    # dos versiones el mismo día: vale la última procesada
    st = st.drop_duplicates(["ticker", "fecha"], keep="last")
    return st.sort_values("fecha", kind="stable")

fe = pd.read_parquet("panel5_features.parquet")
px = pd.read_parquet("panel5_precios.parquet")
act = pd.read_csv("panel5_activos.csv")
sem = (fe[["fecha", "ticker", "ey"]]
       .merge(px, on=["fecha", "ticker"])
       .merge(act[["ticker", "acciones_millones"]], on="ticker")
       .sort_values("fecha"))

pit = pd.merge_asof(sem, estado_pit(fu), on="fecha", by="ticker")
cap = pit["cierre"] * pit["acciones_millones"]
pit["ey_mio"] = pit["ttm"] / cap
# tolerancia: ey publicada con 4 decimales y beneficios redondeados
# al céntimo de millón (pesan cuando el TTM es casi cero)
tol = 1e-4 + 0.005 * pit["ey"].abs()
dif = (pit["ey_mio"] - pit["ey"]).abs()
print(f"{len(pit)} filas; fuera de la tolerancia: {(dif > tol).sum()}")
OUT [02]Beneficio TTM tal como se conocía
292000 filas; fuera de la tolerancia: 0

Las 292.000 filas coinciden dentro de la tolerancia del redondeo. Dos detalles del código son la diferencia entre acertar y no. El primero: dos versiones el mismo día (un informe nuevo y la reexpresión de uno antiguo, como el 5 de mayo de 2014 de DOPE) generan dos estados con la misma fecha, y merge_asof se queda con la última fila que encuentra; si el orden no es estable, a veces es la anterior. Con drop_duplicates(..., keep="last") y un orden estable, el resultado no depende de la suerte (en una primera versión sin esas dos líneas, unas 300 filas salían mal). El segundo: merge_asof incluye la coincidencia exacta, así que un informe publicado el viernes entra en la feature del cierre del viernes. Eso supone que se publicó antes del cierre; si se publicó después, hay que retrasarlo una sesión (ejercicio 1).

La trampa del fin de trimestre

El SUE (standardized unexpected earnings) mide la sorpresa del beneficio: la variación frente al mismo trimestre del año anterior, dividida por la desviación típica de las ocho variaciones anteriores. Se calcula con las cifras originales, las que el mercado vio. Bernard y Thomas (1989)1Nota 1 · V. L. Bernard y J. K. Thomas, «Post-Earnings-Announcement Drift: Delayed Price Response or Risk Premium?», Journal of Accounting Research 27, 1989, pp. 1-36, doi:10.2307/2491062. documentaron que el precio sigue moviéndose en la dirección de la sorpresa durante semanas tras la publicación; en el panel, el generador planta un efecto del SUE que decae con los días desde el informe.

La trampa más frecuente con los fundamentales es indexarlos por el fin del periodo, como si se conocieran ese día. Pasa al descargar series trimestrales de una fuente que solo da el periodo, o al hacer un resample trimestral. Se construyen las dos versiones:

IN [03]python
orig = (fu[fu["version"] == "original"]
        .sort_values(["ticker", "fin_trimestre"]).copy())
orig["dif"] = orig.groupby("ticker")["beneficio"].diff(4)
orig["sd8"] = orig.groupby("ticker")["dif"].transform(
    lambda s: s.shift(1).rolling(8).std())
orig["sue"] = orig["dif"] / orig["sd8"]

def alinea(columna):
    """SUE de cada semana, conocido desde `columna`."""
    ev = (orig[["ticker", columna, "sue"]].dropna()
          .rename(columns={columna: "fecha"}).sort_values("fecha"))
    s = pd.merge_asof(sem[["fecha", "ticker"]], ev,
                      on="fecha", by="ticker")
    return s.pivot(index="fecha", columns="ticker", values="sue")

sue_pub = alinea("fecha_publicacion")
sue_fin = alinea("fin_trimestre")
pub = fe.pivot(index="fecha", columns="ticker", values="sue")
print(f"diferencia máxima con la feature publicada: "
      f"{np.nanmax(np.abs(sue_pub.to_numpy() - pub.to_numpy())):.3f}")
OUT [03]SUE desde los beneficios originales
diferencia máxima con la feature publicada: 0.009

El SUE reconstruido coincide con el publicado salvo el redondeo. Ahora, el IC de las dos alineaciones:

IN [04]python
et = pd.read_parquet("panel5_etiquetas.parquet")
fechas = pd.DatetimeIndex(fe["fecha"].unique())
Y = et.pivot(index="fecha", columns="ticker",
             values="ret_sig_semana").loc[fechas]

def ic(X, y=Y):
    """IC de rangos semanal: media y t."""
    X = X.reindex(index=fechas, columns=y.columns)
    v = X.rank(axis=1).corrwith(y.rank(axis=1), axis=1).dropna()
    t = v.mean() / v.std() * np.sqrt(len(v))
    return round(float(v.mean()), 4), round(float(t), 1)

print("SUE por publicación:     IC, t =", ic(sue_pub))
print("SUE por fin de trimestre: IC, t =", ic(sue_fin))

# ¿dónde está la diferencia? semanas en que el SUE «del fin de
# trimestre» aún no se había publicado
distinto = (sue_fin - sue_pub).abs() > 1e-9
print(f"filas en que difieren: {distinto.to_numpy().mean():.1%}")
print("solo esas filas, falsa:  IC, t =", ic(sue_fin.where(distinto)))
print("solo esas filas, buena:  IC, t =", ic(sue_pub.where(distinto)))
print("resto de filas:          IC, t =", ic(sue_pub.where(~distinto)))
OUT [04]IC con la fecha buena y con la mala
SUE por publicación:     IC, t = (0.0079, 3.7)
SUE por fin de trimestre: IC, t = (0.0292, 12.3)
filas en que difieren: 41.5%
solo esas filas, falsa:  IC, t = (0.0838, 14.3)
solo esas filas, buena:  IC, t = (0.005, 1.1)
resto de filas:          IC, t = (0.0108, 3.2)

Alinear con el fin del trimestre multiplica el IC por casi cuatro (de 0,0079 a 0,0292, con tt = 12,3) y lo deja exactamente en el techo del oráculo del README, el IC del rendimiento esperado verdadero. Un modelo que llegue al techo con una sola feature es, casi seguro, una fuga. Y se puede localizar: las dos versiones solo difieren en el 41,5 % de las filas, las semanas en que la acción ya había cerrado el trimestre y aún no lo había publicado. Ahí la falsa tiene un IC de 0,084 (t = 14,3) y la buena, que todavía usa el trimestre anterior, de 0,005. En el resto de las filas, las dos son la misma feature (0,0108).

Una validación cruzada, por purgada que sea (lección 5.4.2), no detecta esto: la fuga está en la columna, no en el reparto de las filas. Solo se descubre comprobando cuándo se conocía cada dato. La figura de la cabecera muestra por qué la fuga es tan rentable:

IN [05]python
C = px.pivot(index="fecha", columns="ticker", values="cierre")
R = C.pct_change()
A = R.sub(R.mean(axis=1), axis=0)  # menos el mercado

ev = orig.dropna(subset=["sue"]).copy()
ev["k"] = C.index.searchsorted(ev["fecha_publicacion"])
ev["kq"] = C.index.searchsorted(ev["fin_trimestre"])
ev = ev[(ev["kq"] >= 1) & (ev["k"] + 20 < len(C))]
print(f"{len(ev)} publicaciones; sesiones del fin del trimestre a "
      f"la publicación: {(ev['k'] - ev['kq']).mean():.1f} de media")

col = ev["ticker"].map({t: i for i, t in enumerate(C.columns)})
desfases = np.arange(-40, 21)
M = np.stack([A.to_numpy()[ev["k"].to_numpy() + d, col.to_numpy()]
              for d in desfases], axis=1)
quintil = pd.qcut(ev["sue"], 5, labels=False).to_numpy()
car = (pd.DataFrame(M, columns=desfases).groupby(quintil).mean()
       .cumsum(axis=1) * 100)
car[[-40, -27, -10, -1, 0, 1, 5, 20]].round(2)
OUT [05]Rendimiento alrededor de la publicación, por quintil de SUE
23600 publicaciones; sesiones del fin del trimestre a la publicación: 26.1 de media
    -40   -27   -10   -1     0     1     5     20
0 -0.04 -0.31 -0.49 -0.37 -2.35 -2.33 -2.33 -2.46
1 -0.04 -0.23 -0.26 -0.23 -1.08 -1.05 -1.04 -1.11
2  0.04  0.18  0.22  0.05 -0.05 -0.04 -0.06 -0.14
3  0.02  0.26  0.20  0.15  0.92  0.90  0.87  1.09
4  0.04  0.37  0.54  0.67  2.67  2.68  2.72  2.85

Es la figura de la cabecera. Antes de la publicación las curvas se separan poco (+0,67 % el quintil alto y −0,37 % el bajo la víspera), porque el beneficio del generador sigue al precio con retraso. El día de la publicación, el quintil alto gana un 2,0 % frente al mercado y el bajo pierde otro tanto, y después apenas se mueven (+0,18 % y −0,11 % en las 20 sesiones siguientes). La feature alineada con el fin del trimestre conoce la sorpresa unas 26 sesiones antes de ese salto: cuando la publicación cae en la semana siguiente a la fecha de la feature, la etiqueta incluye el salto entero. La deriva posterior, lo único que una estrategia honesta puede capturar, es diez veces menor.

Reexpresiones: original frente a definitivo

Una reexpresión corrige un informe ya publicado. En el panel afecta al 6,7 % de los informes (README de los datos), y cuando llega el precio reacciona. Con las dos versiones de cada informe se puede medir el cambio relativo del beneficio, δ\delta, y la reacción del precio el día en que se conoce:

IN [06]python
import statsmodels.api as sm

clave = ["ticker", "fin_trimestre"]
rx = fu[fu["version"] == "reexpresado"].merge(
    orig[clave + ["beneficio"]], on=clave, suffixes=("", "_orig"))
rx["delta"] = ((rx["beneficio"] - rx["beneficio_orig"])
               / rx["beneficio_orig"].abs())
print(f"{len(rx)} reexpresiones; a la baja: "
      f"{(rx['delta'] < 0).mean():.0%}; delta mediana "
      f"{rx['delta'].median():+.1%}")

# las reexpresiones de informes anteriores a 2011 llevan la fecha
# de la primera sesión de precios: se dejan fuera
rx = rx[rx["fecha_version"] > C.index[0]]
k = C.index.searchsorted(rx["fecha_version"])
cols = rx["ticker"].map({t: i for i, t in enumerate(C.columns)})
rx["r_dia"] = A.to_numpy()[k, cols.to_numpy()]
fit = sm.OLS(rx["r_dia"], sm.add_constant(rx["delta"])).fit()
print(f"{len(rx)} eventos; pendiente {fit.params['delta']:.3f} "
      f"(error típico {fit.bse['delta']:.3f})")
OUT [06]Cambio del beneficio y reacción del precio
1925 reexpresiones; a la baja: 82%; delta mediana -14.8%
1672 eventos; pendiente 0.299 (error típico 0.003)

El 82 % de las reexpresiones es a la baja, con una mediana de −14,8 %. El día del anuncio, el rendimiento de la acción menos el mercado es 0,299 veces δ\delta (error típico 0,003; la verdad plantada es 0,30): una reexpresión del −20 % cuesta de media un 6 % ese día. Las reexpresiones de informes de 2008-2010, anteriores a los precios, aparecen todas con la fecha de la primera sesión y se dejan fuera.

Las bases de datos que guardan solo la última versión de cada informe dan, para todas las fechas pasadas, las cifras corregidas. Se simula con la versión definitiva de cada informe, disponible desde su primera publicación:

IN [07]python
definitivo = (fu.sort_values("fecha_version", kind="stable")
              .drop_duplicates(clave, keep="last"))
# la versión final, como si se conociera desde la publicación
fin = pd.merge_asof(sem, estado_pit(definitivo, "fecha_publicacion"),
                    on="fecha", by="ticker")
ey_pit = pit.pivot(index="fecha", columns="ticker", values="ey_mio")
ey_fin = (fin["ttm"] / (fin["cierre"] * fin["acciones_millones"]))
ey_fin = fin.assign(ey=ey_fin).pivot(index="fecha", columns="ticker",
                                     values="ey")
cambia = (ey_fin - ey_pit).abs() > 1e-9
print(f"filas semanales que cambian: {cambia.to_numpy().mean():.1%}")
print("tal como se conocía: IC, t =", ic(ey_pit))
print("cifras definitivas:  IC, t =", ic(ey_fin))
OUT [07]Rentabilidad del beneficio con las cifras definitivas
filas semanales que cambian: 16.3%
tal como se conocía: IC, t = (0.0087, 4.0)
cifras definitivas:  IC, t = (0.0089, 4.1)

Las cifras definitivas cambian el 16,3 % de las filas semanales de ey, pero el IC con la semana siguiente casi no se mueve (0,0089 frente a 0,0087). No hay contradicción: en estos datos el precio solo reacciona a la reexpresión el día del anuncio, entre 2 y 14 meses después, así que casi ninguna de esas filas cae en una semana con anuncio. La feature contaminada no gana IC, pero un backtest con ella está mal igualmente: filtra empresas por una cifra que nadie tenía (una regla «beneficio positivo» incluiría o excluiría empresas que entonces estaban del otro lado), y cualquier feature construida con la diferencia entre la versión vigente y la definitiva anticipa el salto del anuncio. En datos reales no se sabe de antemano cuál de las dos cosas pasa; por eso se guardan todas las versiones.

Fig. — IC de las features fundamentales y alternativas según cómo se alinean y se rellenan: la línea discontinua es el IC del oráculo (0,029), el techo de los datos

La figura resume las variantes de la lección: la única que toca el techo es la que no debería existir.

Los datos caducan

Una feature con fecha correcta también tiene fecha de caducidad. El generador hace decaer el efecto del SUE como e−d/45e^{-d/45}, con dd los días desde la publicación: a los 45 días vale un 37 % y a los 90, un 14 %. dias_desde_informe permite medirlo:

IN [08]python
dias = fe.pivot(index="fecha", columns="ticker",
                values="dias_desde_informe")
for lo, hi in [(0, 30), (30, 60), (60, 400)]:
    m = (dias >= lo) & (dias < hi)
    print(f"de {lo:2d} a {hi:3d} días: IC, t =", ic(pub.where(m)),
          f"({m.to_numpy().mean():.0%} de las filas)")
OUT [08]IC del SUE según la antigüedad del informe
de  0 a  30 días: IC, t = (0.0153, 3.1) (33% de las filas)
de 30 a  60 días: IC, t = (0.0044, 0.9) (33% de las filas)
de 60 a 400 días: IC, t = (0.0023, 0.4) (34% de las filas)

El IC se concentra en el primer mes tras la publicación (0,0153, t = 3,1) y es indistinguible de cero después (0,0044 y 0,0023). Para un modelo, eso pide una de dos cosas: darle la antigüedad del dato como feature (un árbol aprende la interacción) o construir la feature ya ponderada, sue × exp(−días/45), como en el ejercicio 2. En datos reales la escala de decaimiento no se conoce y estimarla es otra búsqueda que hay que validar fuera de muestra.

Macro: la misma cifra para todas las acciones

Las variables macro (paro, inflación, tipos, PIB) tienen los mismos problemas de fechas que los fundamentales, más uno: las revisiones. La primera cifra del paro de un mes se publica a principios del mes siguiente y se revisa al mes y al año (lección 1.5.2). Los datos del nivel 1 traen las publicaciones de Valdoria, un país ficticio, con sus vintages; se construye para cada semana del panel desde 2019 la última cifra conocida, frente a la cifra final del último mes cerrado, que es lo que da una descarga ingenua:

IN [09]python
mv = pd.read_csv("macro_vintages.csv",
                 parse_dates=["fecha_publicacion"])
paro = mv[mv["serie"] == "paro"].copy()
paro["fin_mes"] = (pd.PeriodIndex(paro["periodo"], freq="M")
                   .to_timestamp(how="end").normalize())
semanas = fechas[(fechas >= "2019-03-01")]

def conocido(d):
    """Último mes publicado en d, con su última versión en d."""
    v = paro[paro["fecha_publicacion"] <= d]
    ult = v[v["periodo"] == v["periodo"].max()]
    return ult.loc[ult["vintage"].idxmax(), "valor"]

pit_paro = pd.Series([conocido(d) for d in semanas], index=semanas)
final = (paro.sort_values("vintage").groupby("periodo").last()
         .set_index("fin_mes")["valor"].sort_index())
ingenuo = final.reindex(semanas, method="ffill")
dif = (pit_paro - ingenuo).abs()
print(f"semanas con un valor distinto: {(dif > 0).mean():.0%}; "
      f"diferencia media {dif.mean():.2f} puntos")
OUT [09]Paro de Valdoria: vintage de cada semana frente a cifra final
semanas con un valor distinto: 55%; diferencia media 0.07 puntos

En el 55 % de las semanas, la serie ingenua usa un valor que no se conocía: unas veces el mes aún no estaba publicado, otras la cifra era otra (la diferencia media, 0,07 puntos, es pequeña frente a un nivel en torno al 4 %, pero en la variación mensual, que es lo que suele usar un modelo, es del mismo orden que la propia variación). Para cualquier dato macro: ALFRED2Nota 2 · Banco de la Reserva Federal de San Luis, ALFRED (Archival FRED), alfred.stlouisfed.org, consultado el 23/10/2026. guarda los vintages de las series de FRED.

La segunda particularidad de una variable macro en un modelo transversal es que vale lo mismo para todas las acciones de una semana: su IC transversal no existe. Solo puede ayudar cambiando el peso de otras features. El panel no está unido a Valdoria, pero tiene un estado del mercado observable, la caída del índice equiponderado desde su máximo de un año, que sirve para el mismo ejemplo:

IN [10]python
indice = (1 + R.mean(axis=1)).cumprod()  # equiponderado
caida = (indice / indice.rolling(252, min_periods=1).max() - 1)
caida = caida.loc[fechas]
mom = fe.pivot(index="fecha", columns="ticker", values="mom_12_1")
for nombre, m in [("mercado a menos de un 10 % del máximo",
                   caida >= -0.10),
                  ("mercado más de un 10 % por debajo", caida < -0.10)]:
    print(f"{nombre}: {m.sum()} semanas; IC, t =",
          ic(mom.loc[m]))
OUT [10]El momentum según el estado del mercado
mercado a menos de un 10 % del máximo: 527 semanas; IC, t = (0.0132, 3.7)
mercado más de un 10 % por debajo: 203 semanas; IC, t = (-0.0065, -1.0)

Con el mercado cerca de su máximo, el momentum tiene un IC de 0,013 (t = 3,7); en las 203 semanas con el mercado más de un 10 % por debajo, de −0,007, sin significación (t = −1,0). Es el crash del momentum que planta el generador en los rebotes tras una crisis. Una variable de estado así entra en un modelo como interacción (un árbol la usa para partir; en un lineal hay que multiplicarla por la feature), y su efecto se estima con pocas semanas: 203 aquí, unas pocas crisis en cualquier muestra real. Es el lugar donde más fácil es sobreajustar.

Sentimiento y datos alternativos: primero, la cobertura

Los datos alternativos son todo lo que no son precios, volúmenes ni estados financieros: transacciones con tarjeta, imágenes de satélite, tráfico web, ofertas de empleo, textos de noticias, de redes o de las propias presentaciones de resultados. El sentimiento de un texto se puede medir con un diccionario (el de Loughran y McDonald, construido para textos financieros, es la referencia3Nota 3 · T. Loughran y B. McDonald, «When Is a Liability Not a Liability? Textual Analysis, Dictionaries, and 10-Ks», Journal of Finance 66(1), 2011, pp. 35-65, doi:10.1111/j.1540-6261.2010.01625.x; el diccionario se distribuye en sraf.nd.edu, consultado el 23/10/2026.) o con un modelo de lenguaje. Antes del IC, cada fuente pasa por las mismas preguntas:

PreguntaPor qué importa
¿Desde cuándo hay historia, y es point-in-time?Muchos proveedores reconstruyen el pasado después (backfill): la historia anterior al lanzamiento no se conocía en su momento
¿Qué acciones cubre y cuáles no?La cobertura suele estar sesgada hacia empresas grandes, de ciertos sectores o de ciertos países
¿Cuándo llega cada dato?La marca de tiempo del proveedor puede ser la del evento, no la de la entrega
¿Qué licencia tiene?Uso, redistribución y coste (lección 1.3.7)
¿Cuánta gente lo compra?Una señal muy vendida decae deprisa

En el panel, sentimiento es un dato alternativo ficticio con un efecto plantado (saturado: los extremos pesan menos que en una relación lineal). Lo primero es la cobertura:

IN [11]python
sent = fe.pivot(index="fecha", columns="ticker", values="sentimiento")
cubierta = sent.notna()
print(f"cobertura media: {cubierta.to_numpy().mean():.0%}; "
      f"acciones con dato alguna vez: {cubierta.any().sum()}")

capz = fe.pivot(index="fecha", columns="ticker",
                values="log_capitalizacion").rank(axis=1, pct=True)
quintil_cap = np.ceil(capz * 5).clip(1, 5)
print("cobertura por quintil de capitalización:",
      [f"{cubierta[quintil_cap == q].sum().sum()
          / (quintil_cap == q).sum().sum():.0%}" for q in range(1, 6)])
por_sector = (fe.groupby("sector")["sentimiento"]
              .apply(lambda s: s.notna().mean()))
print("sector menos y más cubierto:",
      por_sector.idxmin(), f"{por_sector.min():.0%},",
      por_sector.idxmax(), f"{por_sector.max():.0%}")
OUT [11]Quién tiene dato de sentimiento
cobertura media: 68%; acciones con dato alguna vez: 272
cobertura por quintil de capitalización: ['50%', '69%', '68%', '72%', '81%']
sector menos y más cubierto: finanzas 50%, salud 85%

El proveedor cubre 272 de las 400 acciones, siempre las mismas (en datos reales la cobertura suele crecer con el tiempo, lo que añade otro problema: el universo del modelo cambia). La cobertura no es aleatoria: el 50 % de las acciones del quintil más pequeño frente al 81 % de las grandes, y del 50 % en finanzas al 85 % en salud.

Fig. — Cobertura del dato de sentimiento por quintil de capitalización (línea discontinua: media del panel, 68 %): el proveedor ficticio cubre peor a las empresas pequeñas

Un modelo entrenado solo con las acciones cubiertas aprende sobre un universo más grande y más sesgado por sector que el que va a operar. Uno que elimina las filas sin dato cambia el universo de todas las demás features. Lo habitual es mantener las filas y decidir cómo rellenar:

IN [12]python
print("solo acciones con dato:   IC, t =", ic(sent))
print("huecos a 0 (neutro):      IC, t =", ic(sent.fillna(0)))

# para un modelo: valor neutro + indicador de falta de dato
rango = sent.rank(axis=1, pct=True) - 0.5  # en [-0,5; 0,5]
x_modelo = rango.fillna(0.0)
falta = sent.isna().astype(float)
print("rango con huecos a 0:     IC, t =", ic(x_modelo))
print("solo el indicador «falta»: IC, t =", ic(falta))
OUT [12]Tres formas de tratar los huecos
solo acciones con dato:   IC, t = (0.0066, 3.1)
huecos a 0 (neutro):      IC, t = (0.0052, 2.9)
rango con huecos a 0:     IC, t = (0.0048, 2.7)
solo el indicador «falta»: IC, t = (0.0038, 1.8)

Medido solo donde hay dato, el sentimiento tiene un IC de 0,0066. Rellenar los huecos con cero en el valor original lo baja a 0,0052, y con el rango centrado a 0,0048: las acciones sin dato quedan en el medio de la ordenación, mezcladas con las de sentimiento neutro, y diluyen la señal. La práctica para un modelo es mantener el valor neutro y añadir un indicador de falta de dato, para que el modelo distinga «neutro» de «desconocido» (los árboles de boosting tratan los huecos de forma nativa, lección 5.5.3). El propio indicador tiene aquí un IC de 0,004 (t = 1,8) que no es significativo, pero recuerda que la falta de dato puede ser informativa: en datos reales, una empresa deja de tener cobertura cuando se hace pequeña o deja de interesar. Nunca se rellena con la media de toda la muestra, que incluye el futuro (lección 5.2.4).

Errores típicos

  • Indexar por el fin del periodo. Infla el IC hasta el techo y no lo detecta ninguna validación cruzada. La fecha que manda es la de publicación, con su hora.
  • Guardar solo la última versión. Las reexpresiones y las revisiones macro entran en el pasado. Se guardan todas las versiones con la fecha en que se conocieron.
  • Órdenes inestables y empates de fecha. Dos versiones el mismo día, un sort_values no estable y un merge_asof dan resultados distintos según la ejecución.
  • Normalizar un fundamental con la muestra entera. Un z-score o un percentil calculado con todos los años usa el futuro (lección 5.2.4).
  • Usar una variable macro sin interacción en un modelo transversal. No ordena acciones; y si el modelo es de series temporales, se construye con vintages.
  • Evaluar un dato alternativo solo donde hay cobertura y operarlo en todo el universo. El IC medido no es el de la cartera que se va a construir, y la historia anterior al lanzamiento del proveedor puede estar reconstruida.

Ejercicios

  1. Supón que todos los informes se publican después del cierre. Retrasa un día la disponibilidad del SUE y compara su IC con el de la versión de la lección. ¿Cuántas filas cambian? ¿Merece la pena el cuidado?
Solución
IN [13]python
orig["pub_mas_1"] = orig["fecha_publicacion"] + pd.Timedelta(days=1)
sue_mas_1 = alinea("pub_mas_1")
cambia = (sue_mas_1 - sue_pub).abs() > 1e-9
print(f"filas que cambian: {cambia.to_numpy().mean():.2%}")
print("publicación:       IC, t =", ic(sue_pub))
print("publicación + 1 d: IC, t =", ic(sue_mas_1))
print("filas que cambian, publicación:      IC, t =",
      ic(sue_pub.where(cambia)))
print("filas que cambian, publicación + 1 d: IC, t =",
      ic(sue_mas_1.where(cambia)))
OUT [13]El SUE disponible un día después de la publicación
filas que cambian: 1.08%
publicación:       IC, t = (0.0079, 3.7)
publicación + 1 d: IC, t = (0.0076, 3.6)
filas que cambian, publicación:      IC, t = (0.0279, 1.4)
filas que cambian, publicación + 1 d: IC, t = (-0.0017, -0.1)

Solo cambia el 1,08 % de las filas (los informes publicados el mismo viernes de la feature) y el IC total apenas se mueve (0,0079 frente a 0,0076). En esas filas, la versión del mismo día tiene un IC de 0,028 y la retrasada de −0,002, pero con unas cuatro acciones por semana la diferencia no es significativa (t = 1,4). Con features semanales el cuidado cuesta poco y se nota poco; con features diarias afecta a todos los días de publicación, y si la reacción del precio llega al día siguiente (informes tras el cierre), la versión del mismo día captura el salto entero. Sin la hora de publicación, lo prudente es retrasar una sesión.

  1. Construye la feature sue × exp(−días_desde_informe/45) con las columnas publicadas y compara su IC con el del SUE solo. Después, prueba escalas de 15 y 135 días. ¿Qué precaución hace falta para elegir la escala en datos reales?
Solución
IN [14]python
print("SUE solo:               IC, t =", ic(pub))
centrado = pub.rank(axis=1, pct=True) - 0.5  # rango en [-0,5; 0,5]
for escala in [15, 45, 135]:
    x = centrado * np.exp(-dias / escala)
    print(f"rango × exp(-d/{escala:3d}):  IC, t =", ic(x))
print("SUE bruto × exp(-d/45):  IC, t =",
      ic(pub * np.exp(-dias / 45)))
OUT [14]SUE ponderado por su antigüedad
SUE solo:               IC, t = (0.0079, 3.7)
rango × exp(-d/ 15):  IC, t = (0.0084, 4.0)
rango × exp(-d/ 45):  IC, t = (0.0081, 3.9)
rango × exp(-d/135):  IC, t = (0.0081, 3.9)
SUE bruto × exp(-d/45):  IC, t = (0.0081, 3.9)

La ponderación apenas mejora el IC (de 0,0079 a 0,0081-0,0084, dentro del ruido: el error típico de cada cifra es de unos 0,002), aunque el efecto plantado decae exactamente así. Al ordenar, los SUE antiguos ya quedaban cerca del medio si eran moderados, y la diferencia entre escalas de 15 y 135 días es menor que el error. Elegir la escala con la muestra entera sería ajustar un hiperparámetro mirando el resultado: se elige en la parte de entrenamiento de un walk-forward (lección 5.4.3) o se deja que un modelo con la antigüedad como feature aprenda la interacción.

  1. Un proveedor te ofrece un índice de sentimiento de redes sociales «desde 2010» para 3.000 acciones. Lanzó el producto en 2021. Escribe las cinco comprobaciones que harías antes de dedicar una semana a evaluarlo.
Solución
  1. Historia point-in-time. Qué parte de 2010-2021 se calculó en 2021 con el algoritmo actual (y con el universo actual, que excluye las empresas que dejaron de cotizar). Pedir, si existe, el archivo tal como se entregó cada día desde el lanzamiento y evaluar sobre todo con ese tramo.
  2. Marca de tiempo. Si la fecha de cada dato es la de los mensajes o la de la entrega, y a qué hora llega: un dato de las 23:00 de la víspera sirve para la apertura; uno «del día» entregado tras el cierre, no.
  3. Cobertura. Qué fracción del universo tiene dato en cada fecha, por tamaño, sector y país, y cómo ha evolucionado. Medir el IC solo donde hay cobertura y tratar los huecos con un indicador, como en la lección.
  4. Redundancia. IC incremental frente a lo que ya está en el modelo: el sentimiento de redes suele seguir al precio (rendimientos recientes, volumen, noticias de resultados). Si desaparece al controlar por el rendimiento de 5 sesiones, no aporta (como el RSI en la lección 5.2.1).
  5. Licencia y coste. Qué permite el contrato (uso en producción, almacenamiento, redistribución), cuánto cuesta y cuántos clientes lo tienen (lección 1.3.7). Un IC pequeño puede no pagar ni el dato.

Footnotes

  1. V. L. Bernard y J. K. Thomas, «Post-Earnings-Announcement Drift: Delayed Price Response or Risk Premium?», Journal of Accounting Research 27, 1989, pp. 1-36, doi:10.2307/2491062. ↩

  2. Banco de la Reserva Federal de San Luis, ALFRED (Archival FRED), alfred.stlouisfed.org, consultado el 23/10/2026. ↩

  3. T. Loughran y B. McDonald, «When Is a Liability Not a Liability? Textual Analysis, Dictionaries, and 10-Ks», Journal of Finance 66(1), 2011, pp. 35-65, doi:10.1111/j.1540-6261.2010.01625.x; el diccionario se distribuye en sraf.nd.edu, consultado el 23/10/2026. ↩

Respuesta plegada en cada pregunta

Autoevaluación

  1. Un informe trimestral cierra el 31 de marzo y se publica el 6 de mayo. Una feature construida con él, ¿desde qué fecha puede entrar en el modelo?

    • ADesde el cierre del 6 de mayo (o la sesión siguiente si se publicó tras el cierre)
    • BDesde el 31 de marzo, que es el periodo que describe
    • CDesde el 1 de abril
    • DDesde el cierre del trimestre siguiente
    Ver respuesta

    Respuesta A. Desde el cierre del 6 de mayo (o la sesión siguiente si se publicó tras el cierre)

    La fecha que manda es la de publicación (y la hora: un informe publicado tras el cierre solo se puede operar al día siguiente). Indexarlo por el fin del periodo es usar durante 5 semanas una información que nadie tenía.

  2. En el panel, el SUE alineado con el fin del trimestre da un IC de 0,029 (t = 12,3) y alineado con la publicación, 0,008 (t = 3,7). ¿De dónde sale la diferencia?

    • ADe que el SUE del fin de trimestre está mejor calculado
    • BDel redondeo de los beneficios
    • CDe las reexpresiones
    • DDe las semanas en que la acción aún no había publicado: la feature falsa conoce la sorpresa antes del salto del día de resultados, que cae dentro de la etiqueta
    Ver respuesta

    Respuesta D. De las semanas en que la acción aún no había publicado: la feature falsa conoce la sorpresa antes del salto del día de resultados, que cae dentro de la etiqueta

    Entre el fin del trimestre y la publicación (26 sesiones de media) la versión falsa ya tiene el SUE del trimestre nuevo. Cuando la publicación cae en la semana siguiente, la etiqueta incluye el salto del día de resultados, que depende de esa sorpresa. Fuera de esas semanas, las dos versiones son la misma.

  3. Una base de datos de fundamentales guarda solo la última versión de cada informe (con las reexpresiones aplicadas). ¿Qué problema tiene para un backtest?

    • ANinguno: la última versión es la correcta
    • BQue el modelo ve en cada fecha cifras que entonces no se conocían; en el panel, además, sabría qué empresas iban a reexpresar a la baja antes del anuncio, cuyo día el precio cae de media un 30 % del cambio del beneficio
    • CQue ocupa más espacio
    • DQue no tiene fecha de publicación
    Ver respuesta

    Respuesta B. Que el modelo ve en cada fecha cifras que entonces no se conocían; en el panel, además, sabría qué empresas iban a reexpresar a la baja antes del anuncio, cuyo día el precio cae de media un 30 % del cambio del beneficio

    Las cifras reexpresadas no existían cuando se publicó el informe original. En el panel, el 16 % de las filas semanales de la rentabilidad del beneficio cambia, y el día del anuncio de una reexpresión la acción se mueve 0,30 veces el cambio relativo del beneficio.

  4. El tipo de interés oficial es el mismo para las 400 acciones del panel en cada semana. ¿Cómo puede ayudar en un modelo que ordena acciones?

    • ADirectamente: tiene un IC transversal alto
    • BNo puede ayudar de ninguna forma
    • CPor interacción: no ordena acciones por sí solo (su IC transversal no está definido), pero puede cambiar el peso de otras features, como el momentum, según el estado del mercado
    • DSolo si se normaliza por sector
    Ver respuesta

    Respuesta C. Por interacción: no ordena acciones por sí solo (su IC transversal no está definido), pero puede cambiar el peso de otras features, como el momentum, según el estado del mercado

    Una variable igual para todas las acciones no tiene varianza transversal. En un modelo de ranking entra condicionando: en el panel, el IC del momentum es 0,013 cuando el mercado está a menos de un 10 % de su máximo y −0,007 cuando está más abajo.

  5. Un proveedor de sentimiento cubre el 68 % de las acciones del panel: el 80 % de las grandes y el 50 % de las pequeñas. ¿Qué hacer con las acciones sin dato?

    • AEliminar las acciones sin dato de todo el modelo
    • BRellenar con cero sin más
    • CRellenar con la media de la muestra completa
    • DMantenerlas con un valor neutro y una columna que indique la falta de dato, y medir el IC solo donde hay dato; saber que la cobertura está correlada con el tamaño y el sector
    Ver respuesta

    Respuesta D. Mantenerlas con un valor neutro y una columna que indique la falta de dato, y medir el IC solo donde hay dato; saber que la cobertura está correlada con el tamaño y el sector

    Eliminar las acciones cambia el universo (y lo sesga hacia las grandes); rellenar con cero sin indicador mezcla «neutro» con «sin dato» (el IC baja de 0,0066 a 0,0052); la media de la muestra completa usa el futuro. Un valor neutro con indicador deja que el modelo aprenda la diferencia.

Ideas clave

  • Cada informe tiene fin de periodo, fecha de publicación y fechas de versión. Una feature fundamental se construye por merge_asof sobre la fecha en que se conoce cada versión, por acción, y se comprueba recalculándola.
  • Alinear con el fin del trimestre infla el IC del SUE de 0,008 a 0,029, el techo del oráculo: toda la diferencia sale de las semanas en que la acción aún no había publicado.
  • Las reexpresiones cambian el 16 % de las filas de la rentabilidad del beneficio. En la semana siguiente el IC casi no cambia (0,0087 frente a 0,0089), pero el precio salta 0,30 veces el cambio del beneficio el día del anuncio: usar las cifras definitivas es saberlo antes.
  • Los datos con fecha también caducan: el IC del SUE se concentra en el primer mes tras la publicación (0,015) y no se distingue de cero después.
  • Una variable macro entra en un modelo transversal por interacción y se construye con los vintages de cada fecha; con las cifras revisadas, un 55 % de las semanas usaría un valor que no se conocía.
  • Un dato alternativo se evalúa primero por su cobertura: en el panel, el sentimiento cubre el 50 % de las acciones pequeñas y el 80 % de las grandes, y rellenar mal los huecos le quita una quinta parte del IC.

Recursos