---
title: "5.2.2 Fundamentales PIT, macro y datos alternativos"
description: "Fundamentales con fecha de publicación y reexpresiones, la trampa del fin de trimestre medida, macro con vintages y la cobertura de los datos alternativos."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/fundamentales-point-in-time-macro-y-datos-alternativos/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/fundamentales-point-in-time-macro-y-datos-alternativos.png"
---

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

- Curso: [Nivel 5: Machine learning y deep learning (Keras/PyTorch)](https://pythonparatrading.com/curso-python-trading/nivel-5/)
- Módulo: 5.2 · Ingeniería de características
- Duración: 65 min
- Requisitos: [1.4.4 · Universos sin sesgo de supervivencia: deslistados, fusiones y cambios de símbolo](https://pythonparatrading.com/curso-python-trading/nivel-1/sesgo-de-supervivencia-y-point-in-time/), [1.5.1 · Estados financieros, ratios, XBRL y SEC EDGAR](https://pythonparatrading.com/curso-python-trading/nivel-1/estados-financieros-xbrl-y-edgar/), [1.5.2 · Datos macro, calendarios económicos, estimaciones y transcripciones](https://pythonparatrading.com/curso-python-trading/nivel-1/datos-macro-calendarios-y-estimaciones/), [2.3.5 · Factores (value, calidad, baja volatilidad, tamaño) y estrategias de eventos](https://pythonparatrading.com/curso-python-trading/nivel-2/factores-y-estrategias-de-eventos/), [5.2.1 · Features de precio, volumen y microestructura](https://pythonparatrading.com/curso-python-trading/nivel-5/features-de-precio-volumen-y-microestructura/)

## Al terminar esta lección

- Construir features fundamentales point-in-time (beneficio TTM, rentabilidad del beneficio, SUE) desde informes con fecha de publicación y versiones, con `merge_asof` por acción.
- Medir el efecto de la trampa del fin de trimestre en el IC y localizar de dónde sale: las semanas entre el cierre del trimestre y la publicación.
- Distinguir cifras originales y reexpresadas, y medir qué cambia en una feature y en el precio el día de la reexpresión.
- Tratar una variable macro como feature de un modelo transversal (por interacción) y construirla con los vintages de cada fecha.
- Evaluar un dato alternativo por su cobertura (qué acciones, qué sectores, qué tamaños) y decidir cómo tratar los huecos sin sesgar el modelo.

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

| Fecha | Qué es | En el panel | Qué pasa si se usa mal |
|---|---|---|---|
| Fin del periodo | El trimestre que describe el informe | `fin_trimestre` | La feature conoce el resultado 3-9 semanas antes de publicarse |
| Publicación | Cuando el mercado conoce la cifra | `fecha_publicacion` | Es la buena para la primera versión |
| Versión | Desde cuándo se conoce cada versión (original o reexpresada) | `fecha_version` | Con 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.

```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 $t$: la publicación y las reexpresiones
anteriores a $t$, nunca el fin del trimestre por sí solo.

```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]
```
```output
{'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:

```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()}")
```
```output
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)[^1] 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:

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

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

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

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

```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))
```
```output
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.* Seis barras horizontales. SUE alineado con la publicación, IC 0,0079 (t 3,7); SUE alineado con el fin del trimestre, destacado, 0,0292 (t 12,3), justo en el techo del oráculo; rentabilidad del beneficio tal como se conocía 0,0087 y con cifras definitivas 0,0089; sentimiento solo con dato 0,0066 y con huecos a cero 0,0052.

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/45}$, con $d$ 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:

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

```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")
```
```output
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: ALFRED[^2] 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:

```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]))
```
```output
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 referencia[^3]) o con un modelo de lenguaje. Antes del IC, cada fuente pasa
por las mismas preguntas:

| Pregunta | Por 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:

```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%}")
```
```output
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.* Cinco barras horizontales con la fracción de acciones con dato de sentimiento: 50 % en el quintil de las más pequeñas (destacado), 69 %, 68 % y 72 % en los intermedios y 81 % en el de las más grandes. Una línea discontinua marca la media, 68 %.

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:

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

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

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

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

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

[^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](https://doi.org/10.2307/2491062).
[^2]: Banco de la Reserva Federal de San Luis, ALFRED (*Archival FRED*), [alfred.stlouisfed.org](https://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](https://doi.org/10.1111/j.1540-6261.2010.01625.x); el diccionario se
    distribuye en [sraf.nd.edu](https://sraf.nd.edu/loughranmcdonald-master-dictionary/), consultado el 23/10/2026.

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

   - A. Desde el cierre del 6 de mayo (o la sesión siguiente si se publicó tras el cierre)
   - B. Desde el 31 de marzo, que es el periodo que describe
   - C. Desde el 1 de abril
   - D. Desde el cierre del trimestre siguiente

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

   - A. De que el SUE del fin de trimestre está mejor calculado
   - B. Del redondeo de los beneficios
   - C. De las reexpresiones
   - 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

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

   - A. Ninguno: la última versión es la correcta
   - 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
   - C. Que ocupa más espacio
   - D. Que no tiene fecha de publicación

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

   - A. Directamente: tiene un IC transversal alto
   - B. No puede ayudar de ninguna forma
   - 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
   - D. Solo si se normaliza por sector

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

   - A. Eliminar las acciones sin dato de todo el modelo
   - B. Rellenar con cero sin más
   - C. Rellenar con la media de la muestra completa
   - 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

   *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

### Research

- [Alpha Factors y Alpha Trading en Python](https://pythonparatrading.com/alpha-factors-y-alpha-trading-en-python/) · Research-0007 · 5 min
- [Bancos Centrales y Política Monetaria en Python](https://pythonparatrading.com/politica-monetaria-en-python/) · Research-0010 · 15 min

### Documentación

- [pandas: merge_asof](https://pandas.pydata.org/docs/reference/api/pandas.merge_asof.html) · pandas.pydata.org
- [ALFRED: archivo de vintages de FRED (Banco de la Reserva Federal de San Luis)](https://alfred.stlouisfed.org/) · alfred.stlouisfed.org
- [Bernard y Thomas (1989): Post-Earnings-Announcement Drift, Journal of Accounting Research 27](https://doi.org/10.2307/2491062) · doi.org
- [Loughran y McDonald: diccionario de sentimiento para textos financieros (Universidad de Notre Dame)](https://sraf.nd.edu/loughranmcdonald-master-dictionary/) · sraf.nd.edu

### Datos

- [Datos del nivel 5: informes trimestrales del panel con versiones (CSV)](https://pythonparatrading.com/curso/datos/nivel-5/panel5_fundamentales.csv) · Descarga · panel5_fundamentales.csv

Lección anterior: [5.2.1 · Features de precio, volumen y microestructura](https://pythonparatrading.com/curso-python-trading/nivel-5/features-de-precio-volumen-y-microestructura/)

Lección siguiente: [5.2.3 · Diferenciación fraccional para features](https://pythonparatrading.com/curso-python-trading/nivel-5/diferenciacion-fraccional-para-features/)
