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.
- Module
- 6.7 · Alternative data
- Duration
- 70 min
- Prerequisites
- 2.4.7 · Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing, 2.5.2 · In-sample and out-of-sample: anchored and rolling walk-forward, 6.3.1 · Embeddings and vector databases: FAISS, Chroma, pgvector and Qdrant, 6.7.4 · Alternative data vendors and due diligence
- Author
- Jesús Cuesta
- Updated
Level 6 lessons
6.1 · Classical and modern financial NLP
6.2 · LLMs for research and trading
6.3 · Financial RAG
6.4 · AI agents for trading
- 6.4.1 Tool-calling agents: tool calling, ReAct and planning
- 6.4.2 Agent frameworks and MCP
- 6.4.3 TradingAgents-style multi-agent architectures
- 6.4.4 Evidence and limits of agents
- 6.4.5 Guardrails for agents: the agent proposes, the engine decides
- 6.4.6 Research agents: hypotheses, alpha mining and review
6.5 · AI as the quant's copilot
6.6 · Big data for trading
- 6.6.1 When data becomes “big”: ticks, order books, options and universes
- 6.6.2 Parquet, Arrow, Polars streaming and out-of-core DuckDB
- 6.6.3 Dask, Spark and Ray: from one machine to a cluster
- 6.6.4 Time series databases: TimescaleDB, QuestDB, ClickHouse, ArcticDB and kdb+
- 6.6.5 Cloud storage, lakehouse (Delta, Iceberg) and GPU (RAPIDS, cuDF)
6.7 · Alternative data
Four links
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:
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]
- 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.
- 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.
- Nowcasting: with the quarter's spending, before the company reports, its sales surprise against the consensus is estimated.
- 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
with the demand shock that the alternative data measures, a remainder that nobody measures and the consensus error. On earnings day the price reacts to the part of 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:
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)}")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 DENTALThe 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:
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)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:
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)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:
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)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.0The 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:
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))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.1148Joining 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,
It has a flaw that is visible in the formula: sales from a year earlier carry their own unmeasurable remainder , which enters 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,
The ratio 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.
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}")x1: n = 675 in-sample R² = 0.278
x2: n = 575 in-sample R² = 0.366In sample, explains 27.8% of the variance of the surprise (the README value) and 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.
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}")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.566Out of sample, drops to 25.5% and stays at 37.0%; together they do not improve on 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 , available from 2019; exercise 2 replaces it with the walk-forward forecast.
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) 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.3Between 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, , 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 ( with = 1.2% per standard deviation of surprise):
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}") 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.451This 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:
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
- 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
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}")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.
- Repeat the backtest with the walk-forward forecast of (
T["prevision"]) instead of . Do the conclusions by period change?
Solution
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) 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.0With the 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.
- What part of the alpha could be recovered by trading during the quarter? Use the ground truth column
deriva_trimestreofalt6_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
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}")mean drift per quarter: 0.61%
60-session noise: 12.0%
signal-to-noise ratio per event: 0.051The 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
The
difflibbaseline 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
difflibis 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.
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).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.
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.
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
diffliband 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
Books
- McLean, R. D. and Pontiff, J. (2016): Does Academic Research Destroy Stock Return Predictability? Journal of Finance 71(1), 5-32. DOI 10.1111/jofi.12365
- Froot, K., Kang, N., Ozik, G. and Sadka, R. (2016): What Do Measures of Real-time Corporate Sales Tell Us about Earnings Surprises and Post-Announcement Returns? NBER WP 22366