---
title: "6.7.5 From alternative data to a trading signal"
description: "From card descriptors to a tradable signal: entity resolution, sales surprise nowcasting, a backtest with costs and alpha decay."
url: "https://pythonparatrading.com/en/python-trading-course/level-6/from-alternative-data-to-trading-signals/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-6/from-alternative-data-to-trading-signals.png"
---

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

- Course: [Level 6: Generative AI, LLMs, agents, big data and alternative data](https://pythonparatrading.com/en/python-trading-course/level-6/)
- Module: 6.7 · Alternative data
- Duration: 70 min
- Prerequisites: [2.4.7 · Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing](https://pythonparatrading.com/en/python-trading-course/level-2/backtest-transaction-cost-modeling/), [2.5.2 · In-sample and out-of-sample: anchored and rolling walk-forward](https://pythonparatrading.com/en/python-trading-course/level-2/in-sample-out-of-sample-walk-forward/), [6.3.1 · Embeddings and vector databases: FAISS, Chroma, pgvector and Qdrant](https://pythonparatrading.com/en/python-trading-course/level-6/embeddings-and-vector-databases/), [6.7.4 · Alternative data vendors and due diligence](https://pythonparatrading.com/en/python-trading-course/level-6/alternative-data-vendors-and-due-diligence/)

## By the end of this lesson

- Resolve merchant descriptors to companies by comparing string similarity, n-grams, embeddings and token rules, with the threshold chosen outside the evaluation sample.
- Join alternative data to prices through a permanent identifier and the ticker in force on each date, without losing the history of companies that change symbol.
- Build point-in-time features to predict the sales surprise and measure their R² with walk-forward validation against the data's ceiling.
- Backtest the signal around earnings with costs and measure alpha decay as use of the data spreads.

## 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
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\frac{\text{sales}}{\text{consensus}} = d + e - \nu ,
$$

with $d$ the demand shock that the alternative data measures, $e$ a remainder that nobody measures and $\nu$ the
consensus error. On earnings day the price reacts to the part of $u$ 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:

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

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

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

```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)
```
```output
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.* Five horizontal bars. difflib with a fixed 0.75 threshold gets 68.5% of all descriptors right; with the threshold tuned on the other half, 77.6%. Character n-grams get 87.9% and MiniLM embeddings 86.2%. Token rules, highlighted, 100%.

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:

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

$$
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
$e_{t-4}$, which enters $x_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,

$$
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 $r_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.

```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}")
```
```output
x1: n = 675  in-sample R² = 0.278
x2: n = 575  in-sample R² = 0.366
```

In sample, $x_1$ explains 27.8% of the variance of the surprise (the README value) and $x_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.

```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}")
```
```output
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.* Five horizontal bars. Year-on-year growth against consensus gives an R² of 0.278 in sample and 0.255 walk-forward. The level against its own history gives 0.366 in sample and 0.370 walk-forward, highlighted. The ceiling, the part of the surprise the data measure, is 0.566.

Out of sample, $x_1$ drops to 25.5% and $x_2$ stays at 37.0%; together they do not improve on $x_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 $x_1$, available from 2019; exercise 2 replaces it with the
walk-forward forecast.

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

<aside class="callout" data-kind="aviso">

A backtest of quarterly events has few independent observations. Before concluding anything, the standard error of
the result and the number of variants tried (threshold, window, signal) matter as much as the mean: with several
variants, the Deflated Sharpe from lesson 2.5.4.

</aside>

## 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, $\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 ($\gamma(1-\varphi)$ with $\gamma$ = 1.2% per standard deviation of surprise):

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

```mermaid
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.**

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

2. Repeat the backtest with the walk-forward forecast of $x_2$ (`T["prevision"]`) instead of $x_1$. Do the
   conclusions by period change?

**Solution.**

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

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

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

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

   - A. That the lowest threshold should always be used
   - 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
   - C. That `difflib` is useless
   - D. That unrelated descriptors are irrelevant

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

   - A. Nothing: tickers are unique
   - B. The rows are duplicated
   - C. The observations before the change find no price and the company silently disappears from that part of the backtest
   - D. The price adjusts itself

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

   - A. Because it uses more future data
   - B. Because it is a more complex model
   - 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
   - D. By chance

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

   - 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
   - B. Card data got worse in 2022
   - C. The panel lost cards
   - D. The consensus stopped being published

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

   - A. The strategy works in both periods
   - B. Leverage should be raised after 2022
   - C. Costs do not matter with quarterly earnings
   - D. The first period is significant and covers costs; in the second the gross is zero and the net, negative: the alpha is gone

   *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

### Research

- [Alpha factors and alpha trading in Python](https://pythonparatrading.com/en/alpha-factors-and-alpha-trading-in-python/) · Research-0007 · 5 min

### Documentation

- [Python: difflib (SequenceMatcher)](https://docs.python.org/3/library/difflib.html) · docs.python.org
- [Sentence Transformers: documentation](https://sbert.net/) · sbert.net

### 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](https://doi.org/10.1111/jofi.12365) · doi.org
- [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](https://www.nber.org/papers/w22366) · nber.org

### Data

- [Level 6 data: merchant resolution ground truth (CSV)](https://pythonparatrading.com/curso/datos/nivel-6/alt6_comercios_verdad.csv) · Download · alt6_comercios_verdad.csv
- [Level 6 data: quarterly ground truth of the alternative data (CSV)](https://pythonparatrading.com/curso/datos/nivel-6/alt6_verdad_trimestral.csv) · Download · alt6_verdad_trimestral.csv

Previous lesson: [6.7.4 · Alternative data vendors and due diligence](https://pythonparatrading.com/en/python-trading-course/level-6/alternative-data-vendors-and-due-diligence/)

Next lesson: [6.7.6 · Satellite imagery, computer vision and supply chains](https://pythonparatrading.com/en/python-trading-course/level-6/satellite-computer-vision-supply-chain-data/)
