Lesson 6.7.5 · 70 min

From alternative data to a trading signal

A transaction file is not a signal: you have to know which company each merchant belongs to, which ticker it had on each date, which part of the spending the consensus did not expect and whether the market already prices it in. This lesson goes through the whole chain with the course's card data and measures each link against the ground truth: entity resolution goes from 68.5% to 100%, the surprise forecast reaches an out-of-sample R² of 0.37 and the earnings-day alpha disappears after 2021.

Fig. — Correlation of the card signal with the earnings-day abnormal return, by year (top), and the true effect left on that day per standard deviation of surprise (bottom)
Level 6 lessons
  1. 6.1 · Classical and modern financial NLP

    1. 6.1.1 Tokenization, embeddings, financial dictionaries and FinBERT
    2. 6.1.2 Text sources: EDGAR, CNMV, transcripts and social media
    3. 6.1.3 Event studies with text signals, timestamp alignment and topic modeling
  2. 6.2 · LLMs for research and trading

    1. 6.2.1 How an LLM works: from the next token to the API
    2. 6.2.2 Structured prompting: JSON you can validate
    3. 6.2.3 Guidance, KPIs and tone: what an LLM extracts well
    4. 6.2.4 Lightweight fine-tuning for financial tasks: LoRA and QLoRA
    5. 6.2.5 Look-ahead bias in LLMs: the model that already knows the future
  3. 6.3 · Financial RAG

    1. 6.3.1 Embeddings and vector databases: FAISS, Chroma, pgvector and Qdrant
    2. 6.3.2 Chunking financial documents: tables, footnotes and XBRL
    3. 6.3.3 RAG over reports, prospectuses and KIDs, and how to evaluate it
  4. 6.4 · AI agents for trading

    1. 6.4.1 Tool-calling agents: tool calling, ReAct and planning
    2. 6.4.2 Agent frameworks and MCP
    3. 6.4.3 TradingAgents-style multi-agent architectures
    4. 6.4.4 Evidence and limits of agents
    5. 6.4.5 Guardrails for agents: the agent proposes, the engine decides
    6. 6.4.6 Research agents: hypotheses, alpha mining and review
  5. 6.5 · AI as the quant's copilot

    1. 6.5.1 Coding assistants with a test-first workflow
    2. 6.5.2 Automated review of backtests and code migrations
    3. 6.5.3 Risks of AI-generated code
  6. 6.6 · Big data for trading

    1. 6.6.1 When data becomes “big”: ticks, order books, options and universes
    2. 6.6.2 Parquet, Arrow, Polars streaming and out-of-core DuckDB
    3. 6.6.3 Dask, Spark and Ray: from one machine to a cluster
    4. 6.6.4 Time series databases: TimescaleDB, QuestDB, ClickHouse, ArcticDB and kdb+
    5. 6.6.5 Cloud storage, lakehouse (Delta, Iceberg) and GPU (RAPIDS, cuDF)
  7. 6.7 · Alternative data

    1. 6.7.1 Overview and size of the alternative data market
    2. 6.7.2 Types of alternative data: what each one measures
    3. 6.7.3 Responsible web scraping
    4. 6.7.4 Alternative data vendors and due diligence
    5. 6.7.5 From alternative data to a trading signal
    6. 6.7.6 Satellite imagery, computer vision and supply chains

Lesson 6.7.4 decided that the card dataset is usable with the point-in-time archive and normalized by active cards. It still has to be turned into something that can be traded. The chain has four links and each one can break it:

Merchant descriptorCompany: permanent idTicker on each dateQuarterly PIT spendingForecast surprisePrices and earningsPositionNet P&L and its decay
Mermaid source
flowchart TB
  A[Merchant descriptor] --> B[Company: permanent id]
  B --> C[Ticker on each date]
  B --> D[Quarterly PIT spending]
  D --> E[Forecast surprise]
  C --> F[Prices and earnings]
  E --> G[Position]
  F --> G
  G --> H[Net P&L and its decay]
  1. Entity resolution: which company each text on the bank statement belongs to (“SQ DUTNEX SERVICES”, “TST OXOXGO”). A mistake here puts a dental clinic's spending into a travel agency's sales.
  2. Identifier and ticker: the company is stored with an identifier that does not change and is translated into the ticker of each date to join it with prices, news or earnings.
  3. Nowcasting: with the quarter's spending, before the company reports, its sales surprise against the consensus is estimated.
  4. Backtest and decay: the estimated surprise is traded around the release, with costs, and you measure whether the result holds over time.

The data is the course's, with a known answer: alt6_comercios_verdad.csv says which company each descriptor belongs to and alt6_verdad_trimestral.csv says which part of the surprise the data truly measures (pred) and how much effect is left in the price each quarter. The generator's model is simple to state. The sales surprise is

u=log⁡salesconsensus=d+e−ν,u = \log\frac{\text{sales}}{\text{consensus}} = d + e - \nu ,

with dd the demand shock that the alternative data measures, ee a remainder that nobody measures and ν\nu the consensus error. On earnings day the price reacts to the part of uu the market had not yet priced in. It is synthetic data: the effect is planted and its figures say nothing about real data.

Entity resolution

Card descriptors are short texts, in capitals and truncated to 22 characters, with payment processor prefixes (SQ *, PAYPAL *, TST*, SP *), store numbers and abbreviations. Companies publish their brands and subsidiaries in their annual report (acciones6_filiales_marcas.csv), which are the names to compare against. The first task is to normalize both sides the same way:

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

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

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

nombres = pd.concat(
    [emp[["id_empresa"]].assign(nombre=emp["nombre_corto"]),
     marcas[["id_empresa", "nombre"]]], ignore_index=True)
nombres["clave"] = nombres["nombre"].map(limpiar)
desc = verdad["descriptor"].map(limpiar)
print(len(verdad), "descriptors ·", len(nombres),
      "candidate names (120 companies, their brands and subsidiaries)")
for d in ["SQ *DUTNEX SERVICES", "BAGVO TEL #4581",
          "DUTNEX.COM*ONLINE", "BUPDOR DENTAL"]:
    print(f"{d:22s} -> {limpiar(d)}")
OUT [01]Normalize descriptors and names
127 descriptors · 426 candidate names (120 companies, their brands and subsidiaries)
SQ *DUTNEX SERVICES    -> DUTNEX SERVICES
BAGVO TEL #4581        -> BAGVO TEL
DUTNEX.COM*ONLINE      -> DUTNEX
BUPDOR DENTAL          -> BUPDOR DENTAL

The last example is the trap in the set: 18 of the 127 descriptors are merchants unrelated to any company in the universe even though they share the root of the name (a dental clinic, a bar). The baseline is the one in the data README: the most similar candidate name according to difflib.SequenceMatcher, accepted if the similarity exceeds 0.75. To choose the threshold without cheating, the companies are split into two halves (the even-numbered ones are used for tuning; the odd-numbered ones, for evaluation), and the unrelated merchants alternate:

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

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

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

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

S_dif = np.array([[difflib.SequenceMatcher(None, d, c).ratio()
                   for c in nombres["clave"]] for d in desc])
print(f"difflib, fixed 0.75 threshold: "
      f"{acierto(asignar(S_dif, 0.75), np.ones(len(desc), bool)):.1%}")
p_dif = evaluar("difflib", S_dif)
OUT [02]difflib baseline with the threshold chosen outside the evaluation
difflib, fixed 0.75 threshold: 68.5%
difflib   threshold 0.65 · tuning 76.8% · evaluation 77.6% · unrelated 67%

With the fixed 0.75 threshold, the README's 68.5% is reproduced. Chosen on the tuning half, the threshold drops to 0.65 and accuracy on the evaluation half rises to 77.6%, at the cost of attributing part of the unrelated merchants to a company (it gets 67% of them right, versus 94% with 0.75). String similarity compares letters in order and penalizes the difference in length: “DOKRA”, what is left of “SP * DOKRA 617”, is closer to “NOKA”, a brand of another company (0.667), than to “DOKRA COMMUNICATIONS”; and “BUPDOR DENTAL”, an unrelated merchant, scores 0.692 against “BUPDOR TRAVEL”.

Two common alternatives: character n-grams with TF-IDF (cosine similarity between bags of 2-4 letter chunks, robust to abbreviations) and sentence embeddings (lesson 6.3.1), here all-MiniLM-L6-v2:

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

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

modelo = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2",
                             device="cpu", local_files_only=True)
def emb(textos):
    return modelo.encode(list(textos), normalize_embeddings=True,
                         show_progress_bar=False)
S_emb = emb(desc) @ emb(nombres["clave"]).T
p_emb = evaluar("MiniLM", S_emb)
OUT [03]Character n-grams and embeddings
n-grams   threshold 0.65 · tuning 88.4% · evaluation 87.9% · unrelated 22%
MiniLM    threshold 0.65 · tuning 88.4% · evaluation 86.2% · unrelated 61%

Both go from 77% to 86-88% outside the tuning sample, and both fail mainly on the same thing: the unrelated merchants, of which the n-grams get only 22% right and MiniLM 61%. A general-purpose embedding does not know that on a bank statement “DENTAL” after the name of a travel agency points to another business; it sees it as one more word of context. The invented names (“BAGVOR”, “OXOXGO”) have no meaning the model can exploit either: for codes and brands, embeddings are no better than counting letters.

What does work on this data is an explicit rule over tokens: a descriptor belongs to a candidate name if its first token starts the name and every remaining token (of 3 letters or more) is the start of some word in the name. “BAGVO TEL” fits “BAGVOR TELECOM”; “BAGVOR BAKERY” does not, because no word of the company starts with “BAKERY”. If it fits several companies, or none, the descriptor is not assigned and goes to review:

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

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

p_reglas = np.array([reglas(d) for d in desc])
sin = verdad["id_empresa"].to_numpy() == ""
print(f"rules     tuning {acierto(p_reglas, ajuste):.1%} · evaluation "
      f"{acierto(p_reglas, ~ajuste):.1%} · unrelated "
      f"{acierto(p_reglas, sin):.0%}")
resumen = pd.DataFrame({
    "truth": verdad["relacion"],
    "difflib": p_dif == verdad["id_empresa"],
    "MiniLM": p_emb == verdad["id_empresa"],
    "rules": p_reglas == verdad["id_empresa"]})
(100 * resumen.groupby("truth").mean()).round(0)
OUT [04]Token rules with a review queue
rules     tuning 100.0% · evaluation 100.0% · unrelated 100%
              difflib  MiniLM  rules
truth
filial          100.0   100.0  100.0
marca           100.0   100.0  100.0
matriz           67.0    87.0  100.0
sin_relacion     67.0    61.0  100.0
Fig. — Accuracy resolving 127 card descriptors to companies (or to “unrelated”) with four methods, on the evaluation half

The rules get every descriptor right in both halves (the rows are the relationship in the data: filial, subsidiary; marca, brand; matriz, parent; sin_relacion, unrelated). This should not be overrated: the synthetic descriptors follow a few templates and the rules were written by looking at them. On real data the order of work is the same (normalize, generate candidates with n-grams or embeddings, verify with rules and with the merchant category code, MCC, when the vendor provides it) and there is always a manual review queue. The mapping is versioned by date, because brands are bought and sold.

From the identifier to the ticker of each date

With the company resolved, the ticker is still missing. Six companies in the universe change ticker between 2019 and 2023; one of them has card data. The course's prices use the current ticker for the whole history (as many vendors do), but news, social media and any data archived with the ticker of its date use the old one:

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

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

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

Joining by the ticker string of the date leaves the 2020 quarter without a price: GUFB does not exist in the prices. The mistake raises no exception; the row is left with NaN and the company disappears from the backtest before the change. The rule from lesson 1.4.4 applies here too: everything is stored with the permanent identifier (id_empresa; in real data PERMNO, FIGI or whatever the vendor provides) and the ticker is only used for display or to join with a source that has no other key. In what follows, prices are joined by id_empresa and its current ticker, which is how they are stored.

Nowcasting the sales surprise

With the mapping, spending is aggregated by company and quarter with the point-in-time archive (lesson 6.7.4) and normalized by active cards. The last day of the quarter arrives three days after it closes and the company reports between 17 and 62 days later: the feature is available before the result. The previous day's consensus is also public beforehand, so the comparison is legitimate.

The first feature is the one from lesson 6.7.4: year-over-year growth in spending per card against the growth implied by the consensus,

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

It has a flaw that is visible in the formula: sales from a year earlier carry their own unmeasurable remainder et−4e_{t-4}, which enters x1x_1 as noise. The second feature avoids the annual difference: it compares the level of spending with that of the consensus and subtracts the mean of that same ratio over the previous eight quarters,

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

The ratio rtr_t has a level specific to each company (the share of its sales that goes through cards, the panel's coverage) that the past mean removes; it only uses previous quarters, so it is point-in-time.

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

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

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

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

In sample, x1x_1 explains 27.8% of the variance of the surprise (the README value) and x2x_2 36.6%, with 100 fewer company-quarters because it needs two years of history. The in-sample R² of a feature with an estimated slope is optimistic. The honest test is walk-forward (lesson 2.5.2): for each report, the slope is estimated only with the reports published before it, and the forecast error is measured.

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

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

y = T["sorpresa"].to_numpy()
for cols in (["x1"], ["x2"], ["x1", "x2"]):
    p = walk_forward(cols)
    ok = ~np.isnan(p)
    r2 = 1 - ((y[ok] - p[ok]) ** 2).sum() / (
        (y[ok] - y[ok].mean()) ** 2).sum()
    print(f"{' + '.join(cols):8s} n = {ok.sum()}  "
          f"out-of-sample R² = "
          f"{r2:.3f}")
T["prevision"] = walk_forward(["x2"])
verdad_t = pd.read_csv("alt6_verdad_trimestral.csv",
                       parse_dates=["fin_trimestre"])
verdad_t = verdad_t.merge(emp[["ticker", "id_empresa"]], on="ticker")
T = T.merge(verdad_t[["id_empresa", "fin_trimestre", "pred", "phi",
                      "sd_u"]], on=["id_empresa", "fin_trimestre"])
print(f"ceiling, same sample: R² = "
      f"{T['pred'].corr(T['sorpresa']) ** 2:.3f}")
OUT [07]Walk-forward validation of the forecast
x1       n = 525  out-of-sample R² = 0.255
x2       n = 525  out-of-sample R² = 0.370
x1 + x2  n = 525  out-of-sample R² = 0.369
ceiling, same sample: R² = 0.566
Fig. — R² of the sales-surprise nowcast with two point-in-time card-spend features, in sample and with walk-forward validation, against the data ceiling

Out of sample, x1x_1 drops to 25.5% and x2x_2 stays at 37.0%; together they do not improve on x2x_2 alone. The ceiling, the R² of the surprise with the part the data truly measures, is 0.566 in this sample: there is room left, which in this data comes from the panel's composition bias (lesson 6.7.4) and from the 6% of transactions that arrive late. The improvement from 0.255 to 0.370 does not come from a more complex model, but from a better-designed feature. That is the general rule of nowcasting with alternative data: the work lies in building the measure that is comparable with the market's expectation, not in the algorithm.

Backtest around earnings

The signal is ready; what remains is to know whether it makes money. Earnings are released before the open, so the reaction is the return of the release session, from the previous day to the close of that day. The simplest strategy: buy at the previous day's close if the forecast surprise is positive and sell short if it is negative, and close out at the close of earnings day. The return is measured minus the market average (the mean return of the 120 stocks), and the trade costs 20 bp round trip (commission, bid-ask spread and impact for a consumer stock; lesson 2.4.7).

To compare with the README, the backtest signal is x1x_1, available from 2019; exercise 2 replaces it with the walk-forward forecast.

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

def resumen(g):
    return pd.Series({
        "events": len(g),
        "gross (bp)": 1e4 * g["bruto"].mean(),
        "std. error (bp)": 1e4 * g["bruto"].std() / np.sqrt(len(g)),
        "net (bp)": 1e4 * g["neto"].mean(),
        "hit rate (%)": 100 * (g["bruto"] > 0).mean()})

B.groupby("periodo", observed=True).apply(resumen).round(1)
OUT [08]Backtest of the signal around earnings
           events  gross (bp)  std. error (bp)  net (bp)  hit rate (%)
periodo
2019-2021   275.0        91.0             29.5      71.0          55.6
2022        100.0        46.3             51.0      26.3          53.0
2023-2025   300.0        -8.7             28.2     -28.7          52.3

Between 2019 and 2021 the strategy earns 91 bp gross per event with a standard error of 30 bp: about three standard errors, 71 bp net of costs. In 2022, 46 bp with a standard error of 51, which cannot be told apart from zero. Between 2023 and 2025, −9 bp gross and −29 net. The directional hit rate does not exceed 56% in any period: the gain comes from the move being larger when the signal is right. Each event carries noise of about 5 percentage points (the earnings jump from level 5 and the part of the surprise nobody measures), so with a hundred events a year the standard error is 50 bp: measuring differences of tens of bp requires many years of data.

Alpha decay

The strategy's drop after 2021 is not bad luck. In the generator, on 2021-07-01 a study spreads the use of card data. From then on a growing fraction of the market, φ=1−e−years\varphi = 1 - e^{-\text{years}}, uses it: the information is incorporated into the price during the quarter, while the spending happens, and earnings day is left with only the part the data does not measure. The correlation of the signal with the earnings-day return, year by year, against the true effect left on that day (γ(1−φ)\gamma(1-\varphi) with γ\gamma = 1.2% per standard deviation of surprise):

IN [09]python
anual = B.groupby("año").apply(lambda g: pd.Series({
    "events": len(g),
    "correlation": g["x1"].corr(g["anormal"]),
    "true effect (%)": 1.2 * (1 - g["phi"]).mean()}))
print(anual.round(2).to_string())
for a, b in [(2019, 2021), (2023, 2025)]:
    g = B[B["año"].between(a, b)]
    print(f"{a}-{b}: correlation {g['x1'].corr(g['anormal']):.2f}")
despues = T[T["fin_trimestre"] >= "2023-07-01"]
print(f"forecast R² after 2023Q2: "
      f"{despues['prevision'].corr(despues['sorpresa']) ** 2:.3f}")
OUT [09]Alpha decay year by year
      events  correlation  true effect (%)
año
2019    75.0         0.19             1.20
2020   100.0         0.22             1.20
2021   100.0         0.26             1.08
2022   100.0        -0.01             0.47
2023   100.0         0.01             0.17
2024   100.0         0.11             0.06
2025   100.0        -0.06             0.02
2019-2021: correlation 0.22
2023-2025: correlation 0.03
forecast R² after 2023Q2: 0.451

This is the header figure. The correlation is 0.19-0.26 between 2019 and 2021 and falls to values around zero from 2022 on (between −0.06 and 0.11), following the true effect (from 1.2% to 0.02%). Meanwhile, the sales forecast does not get worse: after 2023 its R² is 0.451, higher than over the whole sample. The data still measures the same thing; what has run out is the advantage of knowing it before everyone else.

This is what the literature documents with published anomalies: McLean and Pontiff (2016) study 97 variables that predicted returns and find that the returns of their portfolios are 26% lower out of the papers' sample and 58% lower after publication, and they attribute it to investors learning from the publications. With real-time sales data, Froot, Kang, Ozik and Sadka (2016) build measures of retailers' sales from some 50 million mobile devices and find that they explain sales growth and earnings surprises. For an alternative dataset, the questions that follow are practical:

noyesnoyesSignal measured by yearDrops after a dateKeep measuringStill predicts salesBroken data: due diligenceAlpha spread outTrade earlier or retire
Mermaid source
flowchart TB
  A[Signal measured by year] --> B{Drops after a date}
  B -->|no| C[Keep measuring]
  B -->|yes| D{Still predicts sales}
  D -->|no| E[Broken data: due diligence]
  D -->|yes| F[Alpha spread out]
  F --> G[Trade earlier or retire]

If the signal stops predicting sales, the problem is the data (a change in the panel, the mapping or the delivery: the list from lesson 6.7.4). If it still predicts them but the price no longer reacts on earnings day, the alpha has spread out. The ways out are to trade earlier (with weekly spending, during the quarter, when the information is not yet in the price; in this data, the drift spread over some 60 sessions is small compared with the noise, as exercise 3 measures), to combine it with other sources or to stop paying for it. The decision depends on the cost of the data (lesson 6.7.1), not only on its correlation.

Exercises

  1. The token rules reject a descriptor if it fits several companies. Count how many descriptors fit more than one candidate before applying the exact match, and which ones they are.
Solution
IN [10]python
ambiguos = []
for v, d in zip(verdad["descriptor"], desc):
    c = {i for k, i in zip(nombres["clave"], ids)
         if encaja(d.split(), k)}
    if len(c) > 1:
        ambiguos.append((v, sorted(c)))
print(len(ambiguos))
for v, c in ambiguos:
    print(f"{v:22s} {c}")
OUT [10]Ambiguous descriptors for the rules
4
SP * VELMIO            ['E005', 'E059', 'E076']
VELLU                  ['E041', 'E046', 'E075']
TST* NONO              ['E013', 'E070']
PAYPAL *LUOX           ['E074', 'E081']

They are short brands that are the start of another brand of another company. Without the exact match rule they would go to manual review; with it they are resolved. In real data, the same situation appears with generic names (“HOME”, “FOODS”), and the solution is the same: prioritize the exact match and send the rest to review.

  1. Repeat the backtest with the walk-forward forecast of x2x_2 (T["prevision"]) instead of x1x_1. Do the conclusions by period change?
Solution
IN [11]python
C = T.copy()
C["anormal"] = [anormal.at[f, actual[i]] for f, i
                in zip(C["fecha_publicacion"], C["id_empresa"])]
C = C.dropna(subset=["prevision"])
C["bruto"] = np.sign(C["prevision"]) * C["anormal"]
C["neto"] = C["bruto"] - COSTE
C["año"] = C["fecha_publicacion"].dt.year
C["periodo"] = pd.cut(C["año"], [2018, 2021, 2022, 2025],
                      labels=["2019-2021", "2022", "2023-2025"])
C.groupby("periodo", observed=True).apply(resumen).round(1)
OUT [11]Backtest with the walk-forward forecast
           events  gross (bp)  std. error (bp)  net (bp)  hit rate (%)
periodo
2019-2021   125.0        -9.6             44.9     -29.6          48.0
2022        100.0        74.6             50.7      54.6          58.0
2023-2025   300.0        -1.0             28.2     -21.0          52.0

With the x2x_2 forecast the result changes a lot: −9.6 bp (standard error 45) between 2019 and 2021, +74.6 bp (51) in 2022 and −1.0 bp (28) between 2023 and 2025. None of them can be told apart from zero. The forecast predicts sales better, but it starts later (it needs two years of history and 50 reports for the first slope): in the period before the diffusion only 125 events remain, and with noise of about 5 points per event the standard error is 45 bp. A better sales forecast does not translate measurably into more P&L with so few events, and the variant chosen changes the backtest more than its standard error: if both had been tried and the better one published, that would be selection (lesson 2.5.4). The robust measure is the one in the header, the correlation by year, which does not depend on a trading threshold.

  1. What part of the alpha could be recovered by trading during the quarter? Use the ground truth column deriva_trimestre of alt6_verdad_trimestral.csv (the total return that the diffusion distributes over the quarter's sessions) and compare it with the noise of a 60-session return.
Solution
IN [12]python
vt = verdad_t[verdad_t["phi"] > 0]
vt = vt[vt["id_empresa"].isin(mapa["id_empresa"])]
deriva = vt["deriva_trimestre"].abs().mean()
ruido = anormal[actual[mapa["id_empresa"].unique()]].std().median()
print(f"mean drift per quarter: {100 * deriva:.2f}%")
print(f"60-session noise: {100 * ruido * np.sqrt(60):.1f}%")
razon = deriva / (ruido * np.sqrt(60))
print(f"signal-to-noise ratio per event: {razon:.3f}")
OUT [12]The drift within the quarter versus the noise
mean drift per quarter: 0.61%
60-session noise: 12.0%
signal-to-noise ratio per event: 0.051

The drift that the diffusion distributes within the quarter is small compared with the 60-session noise of an individual stock. With such a low signal-to-noise ratio per event, thousands of company-quarters would be needed to measure it. In this data, whoever arrives late to an alternative dataset has nowhere to go: the alpha has not moved to another exploitable window, it has simply been shared out among everyone who uses the data.

Each answer is folded under its question

Self-assessment

  1. The difflib baseline with a 0.75 threshold gets 68.5% of the 127 descriptors right; with a 0.60 threshold it gets more of those that do belong to a company right, but only a third of the “unrelated” ones. What does it teach?

    • AThat the lowest threshold should always be used
    • BThat the threshold trades errors between false positives (unrelated merchants attributed to a company) and false negatives, and must be chosen on a sample different from the one used for evaluation
    • CThat difflib is useless
    • DThat unrelated descriptors are irrelevant
    Show answer

    Answer B. That the threshold trades errors between false positives (unrelated merchants attributed to a company) and false negatives, and must be chosen on a sample different from the one used for evaluation

    “BUPDOR DENTAL” scores 0.69 against “BUPDOR TRAVEL”, more than the cleaned descriptor “SP * BUPDOR 848” (0.63), which does belong to the company. Lowering the threshold recovers short descriptors and attributes dental clinics to a travel agency. In the lesson, the threshold is chosen on half of the companies and evaluated on the other half.

  2. The course's prices use the current ticker for the whole history and the news uses the one in force on its date. A company changed from GUFB to GUPE on 2021-02-12. What happens if the old data is joined to prices by the ticker string?

    • ANothing: tickers are unique
    • BThe rows are duplicated
    • CThe observations before the change find no price and the company silently disappears from that part of the backtest
    • DThe price adjusts itself
    Show answer

    Answer C. The observations before the change find no price and the company silently disappears from that part of the backtest

    The join is made through a permanent identifier (id_empresa; in real data PERMNO, FIGI or similar) and the ticker is resolved for each date with the history table (lesson 1.4.4).

  3. The simple signal (growth in spending per card minus the growth implied by the consensus) explains 27.8% of the variance of the surprise in sample and 25.5% out of sample. Comparing the level of spending with the consensus against its own history raises the out-of-sample R² to 37%. Why does it improve?

    • ABecause it uses more future data
    • BBecause it is a more complex model
    • CBecause year-over-year growth carries over the unmeasurable error of the same quarter a year earlier; the level against its past mean dilutes it
    • DBy chance
    Show answer

    Answer C. Because year-over-year growth carries over the unmeasurable error of the same quarter a year earlier; the level against its past mean dilutes it

    Consensus growth is measured against sales from a year earlier, which carry their own unmeasurable error e. The level feature only uses past quarters (point-in-time) and averages out that noise. The ceiling, with the true measurable part, is 0.567.

  4. Between 2019 and 2021 the signal has a correlation of 0.22 with the earnings-day return; between 2023 and 2025, 0.03. Which explanation is consistent with the data and with the literature?

    • AMore investors use card data since its use spread, and the price incorporates the information during the quarter: little surprise is left for earnings day
    • BCard data got worse in 2022
    • CThe panel lost cards
    • DThe consensus stopped being published
    Show answer

    Answer A. More investors use card data since its use spread, and the price incorporates the information during the quarter: little surprise is left for earnings day

    The signal still predicts sales just as well (R² of 0.451 after 2023). What changes is how much is left to price in on earnings day: the true effect falls from 1.2% to 0.02% per standard deviation of surprise. McLean and Pontiff (2016) measure the same dynamics in 97 published anomalies.

  5. Each trade in the strategy lasts one session (from the day before to the close of earnings day) and costs 20 bp round trip. Between 2019 and 2021 it earns 91 bp gross per event with a standard error of 30 bp; between 2023 and 2025, −9 bp with a standard error of 28. Which conclusion is correct?

    • AThe strategy works in both periods
    • BLeverage should be raised after 2022
    • CCosts do not matter with quarterly earnings
    • DThe first period is significant and covers costs; in the second the gross is zero and the net, negative: the alpha is gone
    Show answer

    Answer D. The first period is significant and covers costs; in the second the gross is zero and the net, negative: the alpha is gone

    91 / 30 ≈ 3 standard errors. Once the use of the data spread, the gross cannot be told apart from zero and the 20 bp of costs turn it into losses. With noise of 5% per event, hundreds of events are needed to see differences of tens of bp.

Key points

  • Entity resolution is the first filter: with difflib and a fixed threshold 68.5% of the descriptors are assigned correctly; with n-grams or embeddings, 86-88% out of sample; with token rules over names, brands and subsidiaries, 100% on this synthetic data. On real data, rules are combined with manual review.
  • The link to prices is made through a permanent identifier and the ticker in force on each date: joining by the ticker string silently loses the history of companies that change symbol.
  • Well-built point-in-time features matter more than the model: comparing the level of spending with the consensus against its own history raises the out-of-sample R² from 0.255 to 0.370, against a ceiling of 0.567.
  • Backtest around earnings, with costs: 91 bp gross per event between 2019 and 2021 (t ≈ 3) and −9 bp between 2023 and 2025. The signal still predicts sales; what disappears is the part the price did not already reflect.
  • The alpha of an alternative dataset decays as its use spreads: the true earnings-day effect goes from 1.2% to 0.02% per standard deviation of surprise. It has to be measured by period, not over the whole sample.

Resources

Data