Lesson 1.4.4 · 55 min
Universes without survivorship bias: delistings, mergers and symbol changes
Downloading the current members of an index and testing a strategy over their whole history looks reasonable, and it doubles the return. This lesson rebuilds, day by day, the composition of a fictitious 40-stock index over eleven years and measures the bias, its causes and how to handle each exit.
- Module
- 1.4 · Financial data engineering
- Duration
- 55 min
- Prerequisites
- 1.4.3 · Adjusting for splits and dividends: adjusted and unadjusted prices, 0.5.3 · Selection, filtering, groupby, merge, pivot, resample and moving windows
- Author
- Jesús Cuesta
- Updated
Level 1 lessons
1.1 · How markets work
1.2 · Asset classes
- 1.2.1 Stocks: splits, dividends and corporate actions
- 1.2.2 ETFs: replication, tracking error, leveraged ETFs and UCITS
- 1.2.3 Futures: specifications, margin, roll and contango
- 1.2.4 Forex: pairs, pips, lots, rollover and CFDs
- 1.2.5 Options: calls, puts, intrinsic value and time value
- 1.2.6 Crypto: spot, perpetuals, funding and custody
- 1.2.7 Fixed income and interest rates; CFDs and leveraged products
1.3 · Market data sources
- 1.3.1 Types of market data: EOD, bars, ticks, order book and reference
- 1.3.2 Free market data sources
- 1.3.3 Retail and prosumer market data APIs
- 1.3.4 Professional market data providers
- 1.3.5 Crypto and on-chain data
- 1.3.6 Brokers as market data sources
- 1.3.7 Market data licensing and terms of use
- 1.3.8 How to choose a market data provider
1.4 · Financial data engineering
- 1.4.1 Downloading over REST and websockets: pagination, rate limits and retries
- 1.4.2 Data cleaning: missing values, bad ticks, gaps and time zones
- 1.4.3 Adjusting for splits and dividends: adjusted and unadjusted prices
- 1.4.4 Universes without survivorship bias: delistings, mergers and symbol changes
- 1.4.5 Building bars: time, tick, volume, dollar and imbalance
- 1.4.6 Continuous futures series: roll rules, difference adjustment and ratio adjustment
- 1.4.7 Storage: CSV, Parquet, partitioning, DuckDB, TimescaleDB and ArcticDB
- 1.4.8 Reproducible pipeline: download, validation, incremental updates and quality tests
1.5 · Fundamental and macro data
1.6 · Financial exploratory analysis
1.7 · Programmed technical analysis
What today's list does not tell you
The quickest way to build a universe for a stock backtest is to ask any free source for the current members of an index and download each one's history. The result looks complete: 40 stocks, eleven years of prices. What is missing cannot be seen, because it is the companies that are no longer there: those that went bankrupt, those that were acquired and those that left the index by shrinking. A backtest on that universe answers the question “how much would I have earned by choosing in 2015 the companies that would still be in the index in 2025?”, which nobody could ask in 2015.
This is survivorship bias, and lesson 0.1.1 listed it among the classic mistakes. Here it gets measured. The data
is a fictitious 40-stock index between 2015 and 2025 (made-up ZX.. tickers) with three files: additions and removals
of each member, the events that caused each removal and the prices of the 57 stocks that passed through the index,
including those that no longer exist. Adjusted for dividends and splits, as in lesson 1.4.3.
import numpy as np
import pandas as pd
miembros = pd.read_csv("universo_miembros.csv",
parse_dates=["alta", "baja"])
eventos = pd.read_csv("universo_eventos.csv", parse_dates=["fecha"])
precios = pd.read_parquet("universo_precios.parquet")
print(len(miembros), "membership periods for",
miembros["ticker"].nunique(), "stocks")
print(len(precios), "prices for", precios["ticker"].nunique(),
"stocks,", precios["fecha"].min().date(), "to",
precios["fecha"].max().date())
miembros.tail(3)57 membership periods for 57 stocks
137177 prices for 57 stocks, 2015-01-02 to 2025-12-31
ticker alta baja
54 ZXXK 2023-05-01 NaT
55 ZXQK 2024-03-18 NaT
56 ZXAI 2025-03-24 NaTEach row of miembros is a period [alta, baja): the stock belongs to the index from the day of its addition (alta)
until the day before its removal (baja). An empty baja means it is still in. With that, the members on any date
come out of a filter:
def miembros_en(fecha):
fecha = pd.Timestamp(fecha)
dentro = (miembros["alta"] <= fecha) & (
miembros["baja"].isna() | (fecha < miembros["baja"]))
return set(miembros.loc[dentro, "ticker"])
inicio, fin = miembros_en("2015-01-02"), miembros_en("2025-12-31")
print("Members on 2015-01-02:", len(inicio))
print("Members on 2025-12-31:", len(fin))
print("In both lists:", len(inicio & fin))
print("Left:", sorted(inicio - fin))Members on 2015-01-02: 40
Members on 2025-12-31: 40
In both lists: 23
Left: ['ZXAO', 'ZXBA', 'ZXBY', 'ZXCD', 'ZXFA', 'ZXHX', 'ZXIR', 'ZXKB', 'ZXLU', 'ZXOJ', 'ZXOX', 'ZXRQ', 'ZXSW', 'ZXTW', 'ZXUE', 'ZXUR', 'ZXUV']Of the 40 from 2015, only 23 remain in 2025. The other 17 are exactly the ones today's list does not see. Their exits are in the events file, with their reason:
print(eventos["evento"].value_counts().to_dict())
cols = ["fecha", "ticker", "evento", "ticker_nuevo"]
eventos[cols].iloc[[1, 3, 5, 6]]{'exclusion': 7, 'deslistado_quiebra': 4, 'fusion': 4, 'cambio_simbolo': 2}
fecha ticker evento ticker_nuevo
1 2016-05-16 ZXIR deslistado_quiebra NaN
3 2017-06-19 ZXRQ fusion NaN
5 2018-06-04 ZXUV cambio_simbolo ZXMA
6 2018-10-15 ZXUR fusion ZXZNSeventeen events in eleven years, spread out with no pattern: seven removals in quarterly reviews, four bankruptcies (two in 2020), four mergers and two symbol changes. Each type calls for different treatment in the data:
| Event | What happens to the stock | What happens in the index | Last day for the portfolio |
|---|---|---|---|
| Removal | Keeps trading, smaller | Leaves at the quarterly review and another comes in | Sold at the review, at market price |
| Bankruptcy | Stops trading (or moves to illiquid markets) | Leaves on the day of the event | Last price, or zero if there is no recovery |
| Cash merger | Stops trading when the deal closes | Leaves on the day of the event | Collects the cash offer price |
| Stock merger | Stops trading; its shares are exchanged | Leaves on the day of the event | Receives acquirer shares according to the exchange ratio |
| Symbol change | Keeps trading under another ticker | Same company, new ticker | Nothing: the position continues |
The life of a stock in the universe, from its IPO to its end, has this shape. Any serious backtest has to be able to follow it along all its paths, not only along the arrow that reaches the end of the data:
Mermaid source
stateDiagram-v2 [*] --> Listed: IPO Listed --> Member: addition Member --> Listed: removal Member --> Member: symbol change Member --> Bankrupt: bankruptcy Member --> Acquired: merger Bankrupt --> [*] Acquired --> [*]
A point-in-time backtest
The test strategy is the simplest possible, so that the only thing that changes is the universe: an equal-weighted portfolio across the members, rebalanced every day. First, a membership table with one row per session and one column per stock:
cierres = precios.pivot(index="fecha", columns="ticker",
values="cierre")
dias = cierres.index
es_miembro = pd.DataFrame(False, index=dias, columns=cierres.columns)
for t, alta, baja in miembros[["ticker", "alta", "baja"]].itertuples(
index=False):
hasta = baja if pd.notna(baja) else dias[-1] + pd.Timedelta("1D")
es_miembro.loc[(dias >= alta) & (dias < hasta), t] = True
print("Members per session:", es_miembro.sum(axis=1).unique())
es_miembro.loc["2016-05-12":"2016-05-17", ["ZXIR", "ZXQZ"]]Members per session: [40]
ticker ZXIR ZXQZ
fecha
2016-05-12 True False
2016-05-13 True False
2016-05-16 False True
2016-05-17 False TrueAlways 40. The example in the table is a replacement: ZXIR goes bankrupt and leaves on May 16, 2016, and ZXQZ comes in
that same day. To avoid using the future, a day's portfolio is decided with the membership known at the previous
day's close (shift(1)): you buy at the close of day D what is a member on day D and collect the return of day D+1.
The survivors version always uses the 40 members of 2025.
r = cierres.pct_change(fill_method=None)
cartera = es_miembro.shift(1, fill_value=False)
anos = (len(dias) - 1) / 252 # years of 252 sessions
def tasa_anual(rent):
return (1 + rent.fillna(0)).prod() ** (1 / anos) - 1
r_pit = r.where(cartera).mean(axis=1)
supervivientes = sorted(fin)
r_superv = r[supervivientes].mean(axis=1)
for nombre, x in [("point-in-time", r_pit),
("survivors", r_superv)]:
print(f"{nombre:>15}: {tasa_anual(x):.1%} a year, "
f"1 USD → {(1 + x.fillna(0)).prod():.2f} USD") point-in-time: 5.2% a year, 1 USD → 1.74 USD
survivors: 11.0% a year, 1 USD → 3.13 USDThe same strategy, the same period and the same prices: 5.2% a year with the universe that existed and 11.0% with today's. Eleven years later, the dollar invested is worth 1.74 in one case and 3.13 in the other (the annual rates use years of 252 sessions). The figure at the top draws the two curves. No trading rule has changed; only the list of stocks, and the one in the second backtest was written in 2025.
Where the difference comes from
The index is cap-weighted: at each quarterly review, the stocks that have grown come in and those that have shrunk go out. That leaves a clear mark on the prices of the year before each move:
def rent_previa(t, fecha, sesiones=250):
s = cierres[t].dropna()
i = s.index.searchsorted(fecha)
return s.iloc[i - 1] / s.iloc[max(0, i - 1 - sesiones)] - 1
entran = miembros[miembros["alta"] > "2015-01-02"]
primer_precio = entran["ticker"].map(
lambda t: cierres[t].first_valid_index())
entran = entran[primer_precio < entran["alta"]] # with prior history
ex = eventos[eventos["evento"] == "exclusion"]
previa = pd.DataFrame({
"entran": pd.Series([rent_previa(t, a) for t, a in zip(
entran["ticker"], entran["alta"])]),
"excluidos": pd.Series([rent_previa(t, f) for t, f in zip(
ex["ticker"], ex["fecha"])]),
})
print("Stocks:", previa.count().to_dict())
(100 * previa).agg(["mean", "median"]).round(1)Stocks: {'entran': 15, 'excluidos': 7}
entran excluidos
mean 27.1 -19.9
median 16.3 -12.7The ones that came in had gained 27% on average in the year before (median, 16%); the removed ones had lost 20% (median, 13%). (The two stocks that come in through a symbol change have no prior history under their new ticker and do not count; that is why there are 15 entries and not 17.) The backtest with survivors keeps the rise before each stock's entry, which the real index did not have because they were not members yet, and skips the fall of those that left, which the index did suffer. Bankruptcies are the extreme case:
quiebras = eventos[eventos["evento"] == "deslistado_quiebra"]
filas = []
for t in quiebras["ticker"]:
s = cierres[t].dropna()
filas.append({"ticker": t, "ultima_fecha": s.index[-1].date(),
"ultimo": s.iloc[-1],
"ultimo_ano_pct":
100 * (s.iloc[-1] / s.iloc[-251] - 1)})
pd.DataFrame(filas).round(2) ticker ultima_fecha ultimo ultimo_ano_pct
0 ZXIR 2016-05-13 0.90 -96.53
1 ZXTW 2020-05-01 0.37 -97.87
2 ZXFA 2020-08-14 0.37 -95.05
3 ZXSW 2023-04-28 0.63 -97.18In their last year, the four lose between 95% and 98%. For the point-in-time universe they are part of the portfolio until the last day; for the survivors one they never existed.
The last day: delisting returns
When a stock stops trading, the backtest has to decide what return the position has in the step from the last quote to
nothing. With pct_change on a price table, the default answer is “none”: the next day the return is NaN, mean
ignores it and the position disappears at its last price, as if it had been sold at that price. For each type of exit,
that means:
- Cash merger. Correct: the last price is the offer price (153.50, 400.00 and 265.00 USD in the universe) and the shareholder collects that.
- Removal. Correct: the stock keeps trading and is sold at the review.
- Stock merger. Almost: the shareholder does not collect cash but receives acquirer shares. The return on the day of the exchange is the acquirer's, applied to the shares received.
- Bankruptcy. Optimistic: after the bankruptcy, the stock usually stops trading on the main exchange and may end up worthless. Research databases (CRSP is the benchmark) include a delisting return so that it does not have to be assumed; Tyler Shumway showed in 1997 that, for delistings due to bankruptcy and other negative causes, that figure was almost always missing and that the omitted returns were large.
The universe's stock merger, ZXUR acquired by ZXZN at an exchange ratio of 7.45 shares, can be checked:
canje = 7.45
ultimo_zxur = cierres.loc["2018-10-12", "ZXUR"]
print(f"ZXUR on 2018-10-12: {ultimo_zxur:.2f}")
print(f"7.45 × ZXZN on 2018-10-12: "
f"{canje * cierres.loc['2018-10-12', 'ZXZN']:.2f}")
r_canje = canje * cierres.loc["2018-10-15", "ZXZN"] / ultimo_zxur - 1
print(f"ZXUR return on 2018-10-15, via ZXZN: {r_canje:+.2%}")ZXUR on 2018-10-12: 171.57
7.45 × ZXZN on 2018-10-12: 171.57
ZXUR return on 2018-10-15, via ZXZN: +0.18%ZXUR's last price is exactly 7.45 times ZXZN's: the market was already pricing in the exchange. The return of the missing day is ZXZN's, +0.18%. Leaving it out barely changes anything here; with a mispriced exchange ratio or an acquirer that moves a lot, it does. Bankruptcies matter more. The most pessimistic test is to assume that each bankruptcy ends at zero on its delisting day:
r_pit_cero = r.where(cartera)
for t, f in zip(quiebras["ticker"], quiebras["fecha"]):
r_pit_cero.loc[f, t] = -1.0 # −100% on the removal day
print(f"Last price: {tasa_anual(r_pit):.1%} a year")
r_cero = r_pit_cero.mean(axis=1)
print(f"Bankruptcies at 0: {tasa_anual(r_cero):.1%} a year")Last price: 5.2% a year
Bankruptcies at 0: 4.2% a yearOne point a year less. Four bankruptcies in eleven years weigh this much because the portfolio is equal-weighted and rebalanced daily: until the last day it holds 2.5% of the capital in each stock, including the one trading at 0.37 USD, and losing all of it costs 2.5% of the portfolio each time. The point-in-time universe with bankruptcies at zero is the lower bound; with the last price, the upper one. Survivorship bias, by contrast, does not even fall within the interval.
A symbol is not a company
The fourth type of event takes nobody out of the index, but it breaks data joined by ticker. ZXUV is renamed ZXMA on June 4, 2018, and ZXLU becomes ZXBI on January 10, 2022. In the price table they are four columns:
cierres.loc["2018-05-30":"2018-06-06", ["ZXUV", "ZXMA"]]ticker ZXUV ZXMA
fecha
2018-05-30 37.50 NaN
2018-05-31 37.00 NaN
2018-06-01 37.55 NaN
2018-06-04 NaN 37.11
2018-06-05 NaN 36.75
2018-06-06 NaN 37.97By symbol, ZXUV “stops trading” on a Friday and ZXMA “goes public” the following Monday, at almost the same price. That Monday's return exists in neither column, a moving average of ZXMA starts from zero and a search for “ZXMA's history” gives four years instead of eleven. Symbols change through mergers, name changes or exchange changes, and they get reused: a ticker left behind by a bankrupt company can show up years later on another one. That is why serious databases identify each security with a permanent identifier that never changes (in CRSP, the PERMNO) and store separately which symbol it had in each interval.
cambios = eventos[eventos["evento"] == "cambio_simbolo"]
id_de = dict(zip(cambios["ticker_nuevo"], cambios["ticker"]))
def id_permanente(ticker):
while ticker in id_de: # follow the chain backward
ticker = id_de[ticker]
return ticker
precios["id"] = precios["ticker"].map(id_permanente)
por_id = precios.pivot(index="fecha", columns="id", values="cierre")
print("Columns by ticker:", cierres.shape[1],
"· by identifier:", por_id.shape[1])
s = por_id["ZXUV"]
print(f"Return on 2018-06-04: "
f"{s['2018-06-04'] / s['2018-06-01'] - 1:+.2%}")
print("ZXMA history:", s.first_valid_index().date(), "to",
s.last_valid_index().date())Columns by ticker: 57 · by identifier: 55
Return on 2018-06-04: -1.17%
ZXMA history: 2015-01-02 to 2025-12-31With the identifier, ZXUV and ZXMA are a single eleven-year series and the Monday of the change has its return. Here the identifier is the first ticker in the chain, which is enough for two changes; in a real system you use a number or a code that does not look like any symbol, so that nobody mistakes it for one.
Mermaid source
flowchart LR M[Members by date] --> U[Universe at t] I[Permanent id] --> P[Prices by id] U --> B[Backtest] P --> B D[Delistings] --> B
How to get a universe like this
This universe is synthetic and comes complete. With real data, getting it is the hard part:
- Free sources. Almost all of them give only the stocks trading today; delisted ones disappear from search. They are good for learning, not for measuring a stock-selection strategy (lesson 1.3.2).
- Paid providers. Some explicitly sell histories with delisted stocks and historical index compositions; it is one of the questions to ask before paying (lesson 1.3.8).
- Research databases. CRSP, the academic benchmark for the US, has permanent identifiers, delisting returns and the history of each symbol.
- Building your own. If there is only today's data, at least start saving from today: a snapshot of the members at each review and the prices of all stocks, including those that leave. In a few years, the universe will be point-in-time from the first snapshot.
When you do not have a point-in-time universe, the honest thing is to say so and limit the conclusions: a timing strategy on an index or an ETF does not suffer from this bias; one that picks stocks from today's list does.
Exercises
- Repeat the point-in-time backtest rebalancing once a month (on the first day of each month) instead of every day. Does the return change much? And the difference with the survivors?
Solution
def cartera_mensual(r, pertenencia):
pesos, mes, serie = None, None, []
for dia, rd in r.fillna(0).iterrows():
if dia.month != mes: # first day of the month
mes = dia.month
dentro = pertenencia.loc[dia] & cierres.loc[dia].notna()
pesos = dentro / max(dentro.sum(), 1) # equal weights
total = pesos.sum()
serie.append((pesos * rd).sum() / total if total else 0.0)
pesos = pesos * (1 + rd) # the weights drift
return pd.Series(serie, index=r.index)
mensual_pit = cartera_mensual(r, cartera)
fijo = pd.DataFrame(False, index=dias, columns=cierres.columns)
fijo[supervivientes] = True
mensual_sup = cartera_mensual(r, fijo)
print(f"Point-in-time monthly: {tasa_anual(mensual_pit):.1%} a year")
print(f"Survivors monthly: {tasa_anual(mensual_sup):.1%} a year")Point-in-time monthly: 5.6% a year
Survivors monthly: 11.1% a yearThe monthly portfolio lets the weights drift during the month: the stocks that rise gain weight and those that fall lose it, and a stock that stops trading leaves the portfolio at its last price. The return changes somewhat compared with daily rebalancing (5.6% versus 5.2% in the point-in-time universe; 11.1% versus 11.0% with survivors), but the gap between the real universe and the survivors one is still several points a year: the bias does not depend on the rebalancing frequency, but on the list.
- Take the list of members on January 2, 2015 and buy the 40 in equal parts without ever touching the portfolio (buy and hold). What annual return does it have? Compare it with the two previous ones and explain why this portfolio has no survivorship bias even though it does not track the index.
Solution
lista_2015 = sorted(inicio)
p0 = cierres.loc["2015-01-02", lista_2015]
n = (1 / 40) / p0 # 1 USD split up
valor_final = 0.0
for t in lista_2015:
s = por_id[id_permanente(t)].dropna()
if t == "ZXUR": # exchanged for ZXZN
valor_final += n[t] * canje * cierres["ZXZN"].iloc[-1]
else: # last known price
valor_final += n[t] * s.iloc[-1]
print(f"1 USD → {valor_final:.2f} USD, "
f"{valor_final ** (1 / anos) - 1:.1%} a year")1 USD → 2.11 USD, 7.1% a yearThe 2015 list is known in 2015: it does not use the future. The companies that go bankrupt stay in the portfolio at their last price (almost zero), the acquired ones turn into their acquisition price (here they are left in cash, not reinvested), ZXUR is exchanged for ZXZN and ZXUV continues as ZXMA thanks to the permanent identifier. It does not track the index, because it does not add the ones that come in, but it is a portfolio someone could really have held. That its result, 7.1% a year, lies closer to the point-in-time one (5.2%) than to the survivors one (11.0%) confirms that the latter's difference is not a matter of method, but of information from the future.
- A colleague runs a backtest of “buy the 10 S&P 500 stocks with the highest momentum each month” using the member list a free website publishes today. Explain what biases it has and what data would be needed to remove them.
Solution
It has survivorship bias (it only sees the companies still in the index today, not those that went bankrupt, were acquired or left) and a look-ahead bias in the selection: the companies that joined the index during the period appear as eligible before their entry, exactly when they had been rising, which is what a momentum strategy looks for. It would need the historical composition of the index (additions and removals with dates), prices of all the stocks that passed through it, including delisted ones, with their delisting return, and a permanent identifier to follow symbol changes. Without them, the result says nothing about the strategy.
Each answer is folded under its question
Self-assessment
A backtest uses the 40 current members of an index with their whole history since 2015. What mistake does it make?
- ANone, if the prices are adjusted
- BOnly a performance one: it takes longer to compute
- CIt uses too many stocks
- DIt uses information from the future: in 2015 it picks the companies known to still be in the index in 2025, and leaves out those that went bankrupt or exited
Show answer
Answer D. It uses information from the future: in 2015 it picks the companies known to still be in the index in 2025, and leaves out those that went bankrupt or exited
Today's list is the result of eleven years of selection: the ones that rose came in and the ones that fell or disappeared went out. Using it in 2015 is knowing the future. In the course index, the return goes from 5.2% to 11.0% a year.
In the course index, what did the stocks that came in do on average in the year before coming in?
- AThey fell sharply
- BThe same as the index
- CThey rose sharply: that is why they came in (more market cap)
- DThere is no way to know
Show answer
Answer C. They rose sharply: that is why they came in (more market cap)
A cap-weighted index brings in the companies that have grown and expels those that have shrunk. A backtest with survivors keeps the rise before the entry, which the real index did not have, and skips the fall before the exit, which it did have.
A stock in the universe goes bankrupt and its last quote is 0.37 USD. The provider stops giving prices. What does a backtest that ignores the next day assume?
- AThat the position was sold at 0.37
- BThat the position is worth zero
- CThat the stock is still trading
- DNothing
Show answer
Answer A. That the position was sold at 0.37
With no return on the next day, the position disappears from the portfolio at its last price, as if it had been sold at 0.37. A bankruptcy can end at zero for the shareholder; with −100% on the delisting day, the course's point-in-time portfolio goes from 5.2% to 4.2% a year.
ZXUV is renamed ZXMA on 2018-06-04. What happens if the backtest joins the data by symbol?
- ANothing: the provider already handles it
- BZXUV looks delisted and ZXMA a new company with no history: the return on the day of the change is lost and any ZXMA indicator starts from zero
- CThe company is duplicated
- DThe backtest fails with an error
Show answer
Answer B. ZXUV looks delisted and ZXMA a new company with no history: the return on the day of the change is lost and any ZXMA indicator starts from zero
The symbol does not identify the company: it gets reused, and it changes with mergers and name changes. With a permanent identifier, ZXUV and ZXMA are one continuous series; by symbol, they are two unrelated pieces.
The survivors backtest is repeated rebalancing once a month instead of every day. Does the bias disappear?
- AYes: the bias comes from daily rebalancing
- BIt reverses: the survivors return less
- CIt depends on the costs
- DNo: the return barely changes (11.1% versus 11.0%); the bias comes from the list of stocks, not from the frequency
Show answer
Answer D. No: the return barely changes (11.1% versus 11.0%); the bias comes from the list of stocks, not from the frequency
The rebalancing frequency changes the result of both portfolios somewhat, but the gap between them is still more than five points a year. What biases the result is having chosen the stocks with information from 2025.
Key points
- A point-in-time universe is the set of stocks known on each date: members with their addition and removal dates, and prices for all of them, including those that no longer exist.
- Testing with today's survivors uses the future. In the course index: 5.2% a year point-in-time versus 11.0% with survivors.
- The bias comes from the selection: those that came in had been rising (+27% on average in the prior year) and those removed had been falling (−20%).
- Every exit needs its final return: bankruptcy (possibly −100%), cash merger (the offer price), stock merger (the acquirer's shares).
- Symbols change and get reused: join the data by a permanent identifier, never by ticker.