Lesson 5.2.2 · 65 min

Point-in-time fundamentals, macro and alternative data

An accounting figure has three dates: that of the period it describes, that of its publication and that of each later version. This lesson turns quarterly reports into weekly features without looking at the future, measures how much aligning them with the end of the quarter inflates the IC, isolates what restatements change and covers two more families: macro variables, the same for every stock, and alternative data, which only exist for part of the universe.

Fig. — Cumulative return minus the market around the earnings release, by SUE quintile (23,600 releases): almost all the move happens on the release day, about 26 sessions after the quarter end
Level 5 lessons
  1. 5.1 · ML fundamentals applied to finance

    1. 5.1.1 Why financial machine learning is different
    2. 5.1.2 Supervised, unsupervised and reinforcement learning; scikit-learn pipelines
    3. 5.1.3 Economic metrics for trading models: beyond accuracy
  2. 5.2 · Feature engineering

    1. 5.2.1 Price, volume and microstructure features
    2. 5.2.2 Point-in-time fundamentals, macro and alternative data
    3. 5.2.3 Fractional differentiation for features
    4. 5.2.4 Leakage-free normalization and cross-sectional rank
    5. 5.2.5 Feature importance: MDI, MDA, SFI and SHAP
  3. 5.3 · Labelling and sampling

    1. 5.3.1 Fixed-horizon labeling and its problems
    2. 5.3.2 Triple-barrier method: labels that mimic a trade
    3. 5.3.3 Meta-labeling: one model for the side and another for the size
    4. 5.3.4 Label uniqueness, overlap weights and the sequential bootstrap
    5. 5.3.5 Event-based sampling: the symmetric CUSUM filter
  4. 5.4 · Validation for financial series

    1. 5.4.1 Why random k-fold fails on financial data
    2. 5.4.2 Purged k-fold with embargo and CPCV for ML models
    3. 5.4.3 Walk-forward with retraining: windows and frequency
    4. 5.4.4 Backtesting ML signals with costs and Deflated Sharpe
  5. 5.5 · Classical models

    1. 5.5.1 Regularized regressions: Ridge, Lasso, Elastic Net and logistic
    2. 5.5.2 Decision trees, Random Forest and bagging
    3. 5.5.3 Gradient boosting: XGBoost, LightGBM and CatBoost
    4. 5.5.4 Optuna and the risk of overfitting the validation
    5. 5.5.5 Unsupervised learning: PCA, clustering and autoencoders
    6. 5.5.6 Learning to rank for stock selection
  6. 5.6 · Deep learning with Keras 3

    1. 5.6.1 Keras 3: one model, several compute engines
    2. 5.6.2 An MLP on a stock panel: regularize and compare
    3. 5.6.3 1D CNN, LSTM and GRU: windows, scaling and leaks
    4. 5.6.4 Callbacks, a Sharpe loss and the model in production
  7. 5.7 · Deep learning with PyTorch

    1. 5.7.1 PyTorch: tensors, autograd and the training loop
    2. 5.7.2 Time series architectures: TCN, N-BEATS, PatchTST and iTransformer
    3. 5.7.3 Probabilistic models: quantile loss, DeepAR and intervals
    4. 5.7.4 Graph neural networks for asset relationships
    5. 5.7.5 End-to-end portfolio optimization: differentiable layers and Sharpe loss
  8. 5.8 · Time series foundation models

    1. 5.8.1 Time series foundation models: pretrain once, forecast without training
    2. 5.8.2 Foundation model families: architecture, data and license
    3. 5.8.3 In practice: context, quantiles, covariates and fine-tuning
    4. 5.8.4 Foundation models in finance: what the evidence says
    5. 5.8.5 Lab: foundation models vs GARCH, HAR and LightGBM
  9. 5.9 · Reinforcement learning

    1. 5.9.1 MDP, Q-learning, PPO and Gymnasium environments
    2. 5.9.2 RL for execution, market making and dynamic hedging
    3. 5.9.3 Why reinforcement learning overfits in directional trading
  10. 5.10 · Interpretability, robustness and model risk

    1. 5.10.1 SHAP, partial dependence and the stability of feature importances
    2. 5.10.2 Data drift and concept drift: detecting them and what to do
    3. 5.10.3 Ensembles, signal combination and synthetic data

Three dates for each figure

A price is known at the instant it trades. An accounting figure is not: it describes a period that has already ended, it is published weeks later and it can change months afterward. Each figure therefore has three dates:

DateWhat it isIn the panelWhat happens if it is misused
Period endThe quarter the report describesfin_trimestreThe feature knows the result 3-9 weeks before it is published
PublicationWhen the market learns the figurefecha_publicacionIt is the right one for the first version
VersionSince when each version (original or restated) is knownfecha_versionAlways using the latest version, the past carries corrections that nobody had

Lesson 1.5.1 shows how to read financial statements with those dates and how to compute point-in-time trailing twelve-month (TTM) earnings over the last four quarters; 1.5.2 does the same with macro series and their vintages; 2.3.5 measures post-earnings drift as an event strategy. This lesson does not repeat that: it builds the features of a full panel for a model and measures what each mistake costs in the IC, which is what a machine learning model would see.

Quarter endPublicationRestatementFeature at t
Mermaid source
flowchart LR
  Q[Quarter end] --> P[Publication]
  P --> R[Restatement]
  P --> F[Feature at t]
  R --> F

A feature can only use the arrows that reach it at date tt: the publication and the restatements prior to tt, never the end of the quarter on its own.

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("days from quarter end to publication:",
      retraso.describe()[["min", "50%", "max"]].to_dict())
fu[fu["ticker"] == "DOPE"].iloc[18:23, :6]
OUT [01]Reports, versions and delays
{'original': 28400, 'reexpresado': 1925}
days from quarter end to publication: {'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

The panel's 400 reports per quarter are published between 20 and 66 days after the close (38 at the median), and 1,925 rows are restatements: the same figure corrected, with its own fecha_version. In the DOPE excerpt, the report for the first quarter of 2012 is restated on March 11, 2013, almost a year later, and its row appears sorted by that date, next to much more recent reports. Sorting by fecha_version is what makes it possible to rebuild what was known on each day.

The point-in-time state of each stock

The procedure has two steps. First, walk through each stock's versions in the order in which they became known and store, after each one, the state of what was known: here, the TTM earnings with the last four known quarters in their current version. Then, join each panel date with the last state on or before it (merge_asof by stock). To validate the result, the earnings yield (ey, TTM earnings divided by market capitalization) is recomputed and compared with the published one:

IN [02]python
def estado_pit(fu, fecha="fecha_version"):
    """One row per stock and date on which what is known changes:
    earnings of the last 4 known quarters (TTM)."""
    filas = []
    orden = fu.sort_values([fecha, "fin_trimestre"], kind="stable")
    for tk, g in orden.groupby("ticker", sort=False):
        conocido = {}  # fin_trimestre -> current earnings
        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"])
    # two versions on the same day: the last one processed wins
    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
# tolerance: ey published with 4 decimals and earnings rounded
# to 0.01 million (they matter when the TTM is close to zero)
tol = 1e-4 + 0.005 * pit["ey"].abs()
dif = (pit["ey_mio"] - pit["ey"]).abs()
print(f"{len(pit)} rows; outside the tolerance: {(dif > tol).sum()}")
OUT [02]TTM earnings as they were known
292000 rows; outside the tolerance: 0

All 292,000 rows match within the rounding tolerance. Two details of the code make the difference between getting it right and not. The first: two versions on the same day (a new report and the restatement of an old one, as on May 5, 2014 for DOPE) generate two states with the same date, and merge_asof keeps the last row it finds; if the sort is not stable, it is sometimes the earlier one. With drop_duplicates(..., keep="last") and a stable sort, the result does not depend on luck (in a first version without those two lines, about 300 rows came out wrong). The second: merge_asof includes the exact match, so a report published on Friday enters the feature of Friday's close. That assumes it was published before the close; if it was published after, it has to be delayed by one session (exercise 1).

The end-of-quarter trap

The SUE (standardized unexpected earnings) measures the earnings surprise: the change against the same quarter of the previous year, divided by the standard deviation of the eight previous changes. It is computed with the original figures, the ones the market saw. Bernard and Thomas (1989)1Note 1 · V. L. Bernard and 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. documented that the price keeps moving in the direction of the surprise for weeks after the publication; in the panel, the generator plants a SUE effect that decays with the days since the report.

The most frequent trap with fundamentals is indexing them by the end of the period, as if they were known on that day. It happens when downloading quarterly series from a source that only gives the period, or when doing a quarterly resample. Both versions are built:

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 of each week, known from `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"maximum difference with the published feature: "
      f"{np.nanmax(np.abs(sue_pub.to_numpy() - pub.to_numpy())):.3f}")
OUT [03]SUE from the original earnings
maximum difference with the published feature: 0.009

The rebuilt SUE matches the published one except for rounding. Now, the IC of the two alignments:

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):
    """Weekly rank IC: mean and 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 by publication:  IC, t =", ic(sue_pub))
print("SUE by quarter end:  IC, t =", ic(sue_fin))

# where is the difference? weeks in which the "quarter end"
# SUE had not been published yet
distinto = (sue_fin - sue_pub).abs() > 1e-9
print(f"rows where they differ: {distinto.to_numpy().mean():.1%}")
print("only those rows, false: IC, t =", ic(sue_fin.where(distinto)))
print("only those rows, right: IC, t =", ic(sue_pub.where(distinto)))
print("remaining rows:         IC, t =", ic(sue_pub.where(~distinto)))
OUT [04]IC with the right date and with the wrong one
SUE by publication:  IC, t = (0.0079, 3.7)
SUE by quarter end:  IC, t = (0.0292, 12.3)
rows where they differ: 41.5%
only those rows, false: IC, t = (0.0838, 14.3)
only those rows, right: IC, t = (0.005, 1.1)
remaining rows:         IC, t = (0.0108, 3.2)

Aligning with the end of the quarter multiplies the IC by almost four (from 0.0079 to 0.0292, with tt = 12.3) and leaves it exactly at the ceiling of the README oracle, the IC of the true expected return. A model that reaches the ceiling with a single feature is, almost certainly, a leak. And it can be located: the two versions only differ in 41.5% of the rows, the weeks in which the stock had already closed the quarter and had not yet published it. There the false one has an IC of 0.084 (t = 14.3) and the right one, which still uses the previous quarter, 0.005. In the remaining rows, the two are the same feature (0.0108).

A cross-validation, however purged (lesson 5.4.2), does not detect this: the leak is in the column, not in how the rows are split. It is only found by checking when each figure was known. The header figure shows why the leak is so profitable:

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)  # minus the market

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)} publications; sessions from quarter end to "
      f"publication: {(ev['k'] - ev['kq']).mean():.1f} on average")

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]Return around the publication, by SUE quintile
23600 publications; sessions from quarter end to publication: 26.1 on average
    -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

It is the header figure. Before the publication the curves separate little (+0.67% the top quintile and −0.37% the bottom one on the eve), because the generator's earnings follow the price with a lag. On the day of the publication, the top quintile gains 2.0% against the market and the bottom one loses as much, and afterward they barely move (+0.18% and −0.11% over the following 20 sessions). The feature aligned with the end of the quarter knows the surprise some 26 sessions before that jump: when the publication falls in the week after the feature's date, the label includes the whole jump. The subsequent drift, the only thing an honest strategy can capture, is ten times smaller.

Restatements: original against final

A restatement corrects a report that has already been published. In the panel it affects 6.7% of the reports (data README), and when it arrives the price reacts. With the two versions of each report you can measure the relative change in earnings, δ\delta, and the price reaction on the day it becomes known:

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)} restatements; downward: "
      f"{(rx['delta'] < 0).mean():.0%}; median delta "
      f"{rx['delta'].median():+.1%}")

# restatements of reports before 2011 carry the date of the
# first price session: they are left out
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)} events; slope {fit.params['delta']:.3f} "
      f"(standard error {fit.bse['delta']:.3f})")
OUT [06]Change in earnings and price reaction
1925 restatements; downward: 82%; median delta -14.8%
1672 events; slope 0.299 (standard error 0.003)

82% of the restatements are downward, with a median of −14.8%. On the day of the announcement, the stock's return minus the market is 0.299 times δ\delta (standard error 0.003; the planted truth is 0.30): a −20% restatement costs 6% on average that day. The restatements of 2008-2010 reports, prior to the prices, all appear with the date of the first session and are left out.

Databases that store only the latest version of each report give, for all past dates, the corrected figures. It is simulated with the final version of each report, available from its first publication:

IN [07]python
definitivo = (fu.sort_values("fecha_version", kind="stable")
              .drop_duplicates(clave, keep="last"))
# the final version, as if it were known from the publication
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"weekly rows that change: {cambia.to_numpy().mean():.1%}")
print("as it was known: IC, t =", ic(ey_pit))
print("final figures:   IC, t =", ic(ey_fin))
OUT [07]Earnings yield with the final figures
weekly rows that change: 16.3%
as it was known: IC, t = (0.0087, 4.0)
final figures:   IC, t = (0.0089, 4.1)

The final figures change 16.3% of the weekly rows of ey, but the IC with the following week barely moves (0.0089 against 0.0087). There is no contradiction: in these data the price only reacts to the restatement on the day of the announcement, between 2 and 14 months later, so almost none of those rows falls in a week with an announcement. The contaminated feature gains no IC, but a backtest with it is wrong all the same: it filters companies by a figure that nobody had (a “positive earnings” rule would include or exclude companies that were then on the other side), and any feature built with the difference between the current version and the final one anticipates the announcement jump. On real data you do not know in advance which of the two things happens; that is why every version is stored.

Fig. — IC of the fundamental and alternative features depending on how they are aligned and filled: the dashed line is the oracle IC (0.029), the ceiling of the data

The figure summarizes the lesson's variants: the only one that touches the ceiling is the one that should not exist.

Data expire

A feature with the right date also has an expiry date. The generator makes the SUE effect decay as e−d/45e^{-d/45}, with dd the days since the publication: at 45 days it is worth 37% and at 90, 14%. dias_desde_informe makes it possible to measure it:

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"from {lo:2d} to {hi:3d} days: IC, t =", ic(pub.where(m)),
          f"({m.to_numpy().mean():.0%} of the rows)")
OUT [08]SUE IC by the age of the report
from  0 to  30 days: IC, t = (0.0153, 3.1) (33% of the rows)
from 30 to  60 days: IC, t = (0.0044, 0.9) (33% of the rows)
from 60 to 400 days: IC, t = (0.0023, 0.4) (34% of the rows)

The IC is concentrated in the first month after the publication (0.0153, t = 3.1) and is indistinguishable from zero afterward (0.0044 and 0.0023). For a model, that calls for one of two things: giving it the age of the data as a feature (a tree learns the interaction) or building the feature already weighted, sue × exp(−days/45), as in exercise 2. On real data the decay scale is not known and estimating it is another search that has to be validated out of sample.

Macro: the same figure for every stock

Macro variables (unemployment, inflation, rates, GDP) have the same date problems as fundamentals, plus one: revisions. The first unemployment figure for a month is published at the beginning of the following month and is revised a month and a year later (lesson 1.5.2). The level 1 data carry the releases of Valdoria, a fictitious country, with their vintages; for each panel week since 2019 the last known figure is built, against the final figure for the last closed month, which is what a naive download gives:

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):
    """Last month published at d, with its latest version at 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"weeks with a different value: {(dif > 0).mean():.0%}; "
      f"mean difference {dif.mean():.2f} points")
OUT [09]Valdoria unemployment: each week's vintage against the final figure
weeks with a different value: 55%; mean difference 0.07 points

In 55% of the weeks, the naive series uses a value that was not known: sometimes the month had not been published yet, other times the figure was different (the mean difference, 0.07 points, is small against a level of around 4%, but in the monthly change, which is what a model usually uses, it is of the same order as the change itself). For any macro figure: ALFRED2Note 2 · Federal Reserve Bank of St. Louis, ALFRED (Archival FRED), alfred.stlouisfed.org, accessed on October 23, 2026. stores the vintages of the FRED series.

The second peculiarity of a macro variable in a cross-sectional model is that it has the same value for every stock in a week: its cross-sectional IC does not exist. It can only help by changing the weight of other features. The panel is not linked to Valdoria, but it has an observable market state, the drawdown of the equal-weighted index from its one-year high, which serves for the same example:

IN [10]python
indice = (1 + R.mean(axis=1)).cumprod()  # equal-weighted
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 [("market less than 10% below its high",
                   caida >= -0.10),
                  ("market more than 10% below", caida < -0.10)]:
    print(f"{nombre}: {m.sum()} weeks; IC, t =",
          ic(mom.loc[m]))
OUT [10]Momentum by the state of the market
market less than 10% below its high: 527 weeks; IC, t = (0.0132, 3.7)
market more than 10% below: 203 weeks; IC, t = (-0.0065, -1.0)

With the market close to its high, momentum has an IC of 0.013 (t = 3.7); in the 203 weeks with the market more than 10% below, −0.007, with no significance (t = −1.0). It is the momentum crash that the generator plants in the rebounds after a crisis. A state variable like this enters a model as an interaction (a tree uses it to split; in a linear model it has to be multiplied by the feature), and its effect is estimated with few weeks: 203 here, a few crises in any real sample. It is the place where overfitting is easiest.

Sentiment and alternative data: coverage first

Alternative data are everything that is not prices, volumes or financial statements: card transactions, satellite images, web traffic, job postings, texts from news, from social media or from the earnings presentations themselves. The sentiment of a text can be measured with a dictionary (the Loughran and McDonald one, built for financial texts, is the reference3Note 3 · T. Loughran and 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; the dictionary is distributed at sraf.nd.edu, accessed on October 23, 2026.) or with a language model. Before the IC, each source goes through the same questions:

QuestionWhy it matters
Since when is there history, and is it point-in-time?Many vendors rebuild the past afterward (backfill): the history before the launch was not known at the time
Which stocks does it cover and which not?Coverage is usually biased toward large companies, certain sectors or certain countries
When does each data point arrive?The vendor's timestamp may be that of the event, not that of the delivery
What license does it have?Use, redistribution and cost (lesson 1.3.7)
How many people buy it?A widely sold signal decays quickly

In the panel, sentimiento is a fictitious alternative dataset with a planted effect (saturated: the extremes weigh less than in a linear relationship). The first thing is coverage:

IN [11]python
sent = fe.pivot(index="fecha", columns="ticker", values="sentimiento")
cubierta = sent.notna()
print(f"mean coverage: {cubierta.to_numpy().mean():.0%}; "
      f"stocks with data at some point: {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("coverage by market cap quintile:",
      [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("least and most covered sector:",
      por_sector.idxmin(), f"{por_sector.min():.0%},",
      por_sector.idxmax(), f"{por_sector.max():.0%}")
OUT [11]Who has sentiment data
mean coverage: 68%; stocks with data at some point: 272
coverage by market cap quintile: ['50%', '69%', '68%', '72%', '81%']
least and most covered sector: finanzas 50%, salud 85%

The vendor covers 272 of the 400 stocks, always the same ones (on real data coverage usually grows over time, which adds another problem: the model's universe changes). Coverage is not random: 50% of the stocks in the smallest quintile against 81% of the large ones, and from 50% in financials (finanzas) to 85% in health care (salud).

Fig. — Coverage of the sentiment data by market-cap quintile (dashed line: panel average, 68%): the fictional vendor covers small companies worse

A model trained only on the covered stocks learns about a universe that is larger and more biased by sector than the one it is going to trade. One that removes the rows without data changes the universe of all the other features. The usual approach is to keep the rows and decide how to fill:

IN [12]python
print("only stocks with data:     IC, t =", ic(sent))
print("gaps at 0 (neutral):       IC, t =", ic(sent.fillna(0)))

# for a model: neutral value + missing-data indicator
rango = sent.rank(axis=1, pct=True) - 0.5  # in [-0.5, 0.5]
x_modelo = rango.fillna(0.0)
falta = sent.isna().astype(float)
print("rank with gaps at 0:       IC, t =", ic(x_modelo))
print("only the “missing” flag:   IC, t =", ic(falta))
OUT [12]Three ways of handling the gaps
only stocks with data:     IC, t = (0.0066, 3.1)
gaps at 0 (neutral):       IC, t = (0.0052, 2.9)
rank with gaps at 0:       IC, t = (0.0048, 2.7)
only the “missing” flag:   IC, t = (0.0038, 1.8)

Measured only where there is data, sentiment has an IC of 0.0066. Filling the gaps with zero in the original value lowers it to 0.0052, and with the centered rank to 0.0048: the stocks without data end up in the middle of the ranking, mixed with those of neutral sentiment, and dilute the signal. The practice for a model is to keep the neutral value and add a missing-data indicator, so that the model distinguishes “neutral” from “unknown” (boosting trees handle gaps natively, lesson 5.5.3). The indicator itself has an IC of 0.004 here (t = 1.8) that is not significant, but it is a reminder that missing data can be informative: on real data, a company loses coverage when it becomes small or stops being of interest. Never fill with the mean of the whole sample, which includes the future (lesson 5.2.4).

Common mistakes

  • Indexing by the end of the period. It inflates the IC up to the ceiling and no cross-validation detects it. The date that counts is the publication date, with its time.
  • Storing only the latest version. Restatements and macro revisions leak into the past. Every version is stored with the date on which it became known.
  • Unstable sorts and date ties. Two versions on the same day, a non-stable sort_values and a merge_asof give different results depending on the run.
  • Normalizing a fundamental with the whole sample. A z-score or a percentile computed with all the years uses the future (lesson 5.2.4).
  • Using a macro variable without an interaction in a cross-sectional model. It does not rank stocks; and if the model is a time series one, it is built with vintages.
  • Evaluating an alternative dataset only where there is coverage and trading it across the whole universe. The measured IC is not that of the portfolio that is going to be built, and the history before the vendor's launch may be reconstructed.

Exercises

  1. Suppose all reports are published after the close. Delay the availability of the SUE by one day and compare its IC with that of the lesson's version. How many rows change? Is the care worth it?
Solution
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"rows that change: {cambia.to_numpy().mean():.2%}")
print("publication:       IC, t =", ic(sue_pub))
print("publication + 1 d: IC, t =", ic(sue_mas_1))
print("rows that change, publication:       IC, t =",
      ic(sue_pub.where(cambia)))
print("rows that change, publication + 1 d: IC, t =",
      ic(sue_mas_1.where(cambia)))
OUT [13]The SUE available one day after the publication
rows that change: 1.08%
publication:       IC, t = (0.0079, 3.7)
publication + 1 d: IC, t = (0.0076, 3.6)
rows that change, publication:       IC, t = (0.0279, 1.4)
rows that change, publication + 1 d: IC, t = (-0.0017, -0.1)

Only 1.08% of the rows change (the reports published on the same Friday as the feature) and the total IC barely moves (0.0079 against 0.0076). In those rows, the same-day version has an IC of 0.028 and the delayed one −0.002, but with about four stocks per week the difference is not significant (t = 1.4). With weekly features the care costs little and shows little; with daily features it affects every publication day, and if the price reaction arrives the next day (reports after the close), the same-day version captures the whole jump. Without the publication time, the prudent thing is to delay by one session.

  1. Build the feature sue × exp(−dias_desde_informe/45) with the published columns and compare its IC with that of the SUE alone. Then, try scales of 15 and 135 days. What precaution is needed to choose the scale on real data?
Solution
IN [14]python
print("SUE alone:               IC, t =", ic(pub))
centrado = pub.rank(axis=1, pct=True) - 0.5  # rank in [-0.5, 0.5]
for escala in [15, 45, 135]:
    x = centrado * np.exp(-dias / escala)
    print(f"rank × exp(-d/{escala:3d}):   IC, t =", ic(x))
print("raw SUE × exp(-d/45):    IC, t =",
      ic(pub * np.exp(-dias / 45)))
OUT [14]SUE weighted by its age
SUE alone:               IC, t = (0.0079, 3.7)
rank × exp(-d/ 15):   IC, t = (0.0084, 4.0)
rank × exp(-d/ 45):   IC, t = (0.0081, 3.9)
rank × exp(-d/135):   IC, t = (0.0081, 3.9)
raw SUE × exp(-d/45):    IC, t = (0.0081, 3.9)

The weighting barely improves the IC (from 0.0079 to 0.0081-0.0084, within the noise: the standard error of each figure is about 0.002), even though the planted effect decays exactly like this. When ranking, old SUEs already ended up near the middle if they were moderate, and the difference between scales of 15 and 135 days is smaller than the error. Choosing the scale with the whole sample would be tuning a hyperparameter by looking at the result: it is chosen in the training part of a walk-forward (lesson 5.4.3) or a model with the age as a feature is left to learn the interaction.

  1. A vendor offers you a social media sentiment index “since 2010” for 3,000 stocks. It launched the product in 2021. Write down the five checks you would make before devoting a week to evaluating it.
Solution
  1. Point-in-time history. Which part of 2010-2021 was computed in 2021 with the current algorithm (and with the current universe, which excludes the companies that were delisted). Ask, if it exists, for the archive as it was delivered each day since the launch and evaluate mainly on that stretch.
  2. Timestamp. Whether the date of each data point is that of the messages or that of the delivery, and at what time it arrives: a data point from 23:00 the evening before is usable for the open; one “for the day” delivered after the close is not.
  3. Coverage. What fraction of the universe has data on each date, by size, sector and country, and how it has evolved. Measure the IC only where there is coverage and handle the gaps with an indicator, as in the lesson.
  4. Redundancy. Incremental IC against what is already in the model: social media sentiment usually follows the price (recent returns, volume, earnings news). If it disappears when controlling for the 5-session return, it adds nothing (like the RSI in lesson 5.2.1).
  5. License and cost. What the contract allows (production use, storage, redistribution), how much it costs and how many clients have it (lesson 1.3.7). A small IC may not even pay for the data.

Footnotes

  1. V. L. Bernard and 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. Federal Reserve Bank of St. Louis, ALFRED (Archival FRED), alfred.stlouisfed.org, accessed on October 23, 2026. ↩

  3. T. Loughran and 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; the dictionary is distributed at sraf.nd.edu, accessed on October 23, 2026. ↩

Each answer is folded under its question

Self-assessment

  1. A quarterly report closes on March 31 and is published on May 6. From what date can a feature built with it enter the model?

    • AFrom the close of May 6 (or the next session if it was published after the close)
    • BFrom March 31, which is the period it describes
    • CFrom April 1
    • DFrom the close of the following quarter
    Show answer

    Answer A. From the close of May 6 (or the next session if it was published after the close)

    The date that counts is the publication date (and the time: a report published after the close can only be traded the next day). Indexing it by the end of the period means using, for 5 weeks, information that nobody had.

  2. In the panel, the SUE aligned with the end of the quarter gives an IC of 0.029 (t = 12.3) and aligned with the publication, 0.008 (t = 3.7). Where does the difference come from?

    • AFrom the end-of-quarter SUE being better computed
    • BFrom the rounding of earnings
    • CFrom the restatements
    • DFrom the weeks in which the stock had not yet published: the false feature knows the surprise before the earnings-day jump, which falls inside the label
    Show answer

    Answer D. From the weeks in which the stock had not yet published: the false feature knows the surprise before the earnings-day jump, which falls inside the label

    Between the end of the quarter and the publication (26 sessions on average) the false version already has the new quarter's SUE. When the publication falls in the following week, the label includes the earnings-day jump, which depends on that surprise. Outside those weeks, the two versions are the same.

  3. A fundamentals database stores only the latest version of each report (with the restatements applied). What problem does it have for a backtest?

    • ANone: the latest version is the correct one
    • BThe model sees on each date figures that were not known then; in the panel, moreover, it would know which companies were going to restate downward before the announcement, on whose day the price falls on average by 30% of the change in earnings
    • CIt takes up more space
    • DIt has no publication date
    Show answer

    Answer B. The model sees on each date figures that were not known then; in the panel, moreover, it would know which companies were going to restate downward before the announcement, on whose day the price falls on average by 30% of the change in earnings

    The restated figures did not exist when the original report was published. In the panel, 16% of the weekly rows of the earnings yield change, and on the day a restatement is announced the stock moves 0.30 times the relative change in earnings.

  4. The official interest rate is the same for the 400 stocks in the panel each week. How can it help in a model that ranks stocks?

    • ADirectly: it has a high cross-sectional IC
    • BIt cannot help in any way
    • CThrough an interaction: it does not rank stocks on its own (its cross-sectional IC is not defined), but it can change the weight of other features, such as momentum, depending on the state of the market
    • DOnly if it is normalized by sector
    Show answer

    Answer C. Through an interaction: it does not rank stocks on its own (its cross-sectional IC is not defined), but it can change the weight of other features, such as momentum, depending on the state of the market

    A variable that is the same for every stock has no cross-sectional variance. In a ranking model it enters by conditioning: in the panel, the momentum IC is 0.013 when the market is less than 10% below its high and −0.007 when it is further down.

  5. A sentiment vendor covers 68% of the panel's stocks: 80% of the large ones and 50% of the small ones. What should you do with the stocks without data?

    • ARemove the stocks without data from the whole model
    • BSimply fill with zero
    • CFill with the mean of the full sample
    • DKeep them with a neutral value and a column indicating the missing data, and measure the IC only where there is data; be aware that coverage is correlated with size and sector
    Show answer

    Answer D. Keep them with a neutral value and a column indicating the missing data, and measure the IC only where there is data; be aware that coverage is correlated with size and sector

    Removing the stocks changes the universe (and biases it toward the large ones); filling with zero without an indicator mixes “neutral” with “no data” (the IC falls from 0.0066 to 0.0052); the mean of the full sample uses the future. A neutral value with an indicator lets the model learn the difference.

Key points

  • Each report has a period end, a publication date and version dates. A fundamental feature is built by merge_asof on the date each version becomes known, by stock, and is checked by recomputing it.
  • Aligning with the end of the quarter inflates the SUE's IC from 0.008 to 0.029, the oracle's ceiling: the whole difference comes from the weeks in which the stock had not yet published.
  • Restatements change 16% of the rows of the earnings yield. In the following week the IC barely changes (0.0087 against 0.0089), but the price jumps 0.30 times the change in earnings on the day of the announcement: using the final figures is knowing it in advance.
  • Dated data also expire: the SUE's IC is concentrated in the first month after publication (0.015) and cannot be distinguished from zero afterward.
  • A macro variable enters a cross-sectional model through an interaction and is built with the vintages of each date; with the revised figures, 55% of the weeks would use a value that was not known.
  • An alternative dataset is first evaluated by its coverage: in the panel, sentiment covers 50% of the small stocks and 80% of the large ones, and filling the gaps badly takes away a fifth of its IC.

Resources