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.

Fig. — 1 USD in an equal-weight portfolio of the course index (2015-2025): each day's members against the 40 survivors of 2025
Level 1 lessons
  1. 1.1 · How markets work

    1. 1.1.1 Market participants and trading venues
    2. 1.1.2 Sessions, auctions, holidays and calendars
    3. 1.1.3 Order book: bid, ask, spread and depth
    4. 1.1.4 Order types
    5. 1.1.5 Costs, settlement, leverage and margin
  2. 1.2 · Asset classes

    1. 1.2.1 Stocks: splits, dividends and corporate actions
    2. 1.2.2 ETFs: replication, tracking error, leveraged ETFs and UCITS
    3. 1.2.3 Futures: specifications, margin, roll and contango
    4. 1.2.4 Forex: pairs, pips, lots, rollover and CFDs
    5. 1.2.5 Options: calls, puts, intrinsic value and time value
    6. 1.2.6 Crypto: spot, perpetuals, funding and custody
    7. 1.2.7 Fixed income and interest rates; CFDs and leveraged products
  3. 1.3 · Market data sources

    1. 1.3.1 Types of market data: EOD, bars, ticks, order book and reference
    2. 1.3.2 Free market data sources
    3. 1.3.3 Retail and prosumer market data APIs
    4. 1.3.4 Professional market data providers
    5. 1.3.5 Crypto and on-chain data
    6. 1.3.6 Brokers as market data sources
    7. 1.3.7 Market data licensing and terms of use
    8. 1.3.8 How to choose a market data provider
  4. 1.4 · Financial data engineering

    1. 1.4.1 Downloading over REST and websockets: pagination, rate limits and retries
    2. 1.4.2 Data cleaning: missing values, bad ticks, gaps and time zones
    3. 1.4.3 Adjusting for splits and dividends: adjusted and unadjusted prices
    4. 1.4.4 Universes without survivorship bias: delistings, mergers and symbol changes
    5. 1.4.5 Building bars: time, tick, volume, dollar and imbalance
    6. 1.4.6 Continuous futures series: roll rules, difference adjustment and ratio adjustment
    7. 1.4.7 Storage: CSV, Parquet, partitioning, DuckDB, TimescaleDB and ArcticDB
    8. 1.4.8 Reproducible pipeline: download, validation, incremental updates and quality tests
  5. 1.5 · Fundamental and macro data

    1. 1.5.1 Financial statements, ratios, XBRL and SEC EDGAR
    2. 1.5.2 Macro data, economic calendars, estimates and transcripts
  6. 1.6 · Financial exploratory analysis

    1. 1.6.1 Returns, volatility, rolling correlations and drawdown
    2. 1.6.2 Stylized facts: fat tails, clustering, autocorrelation and leverage
    3. 1.6.3 Seasonality: day of the week, month, turn of the month and intraday sessions
  7. 1.7 · Programmed technical analysis

    1. 1.7.1 Technical indicators in Python
    2. 1.7.2 Your own implementation versus TA-Lib: validating results
    3. 1.7.3 Candlestick patterns and support and resistance
    4. 1.7.4 Multi-timeframe analysis without look-ahead

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.

IN [01]python
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)
OUT [01]The three universe files
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  NaT

Each 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:

IN [02]python
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))
OUT [02]Members on a date
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:

IN [03]python
print(eventos["evento"].value_counts().to_dict())
cols = ["fecha", "ticker", "evento", "ticker_nuevo"]
eventos[cols].iloc[[1, 3, 5, 6]]
OUT [03]Why they left
{'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         ZXZN
Fig. — The 17 events of the course universe between 2015 and 2025, by type

Seventeen 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:

EventWhat happens to the stockWhat happens in the indexLast day for the portfolio
RemovalKeeps trading, smallerLeaves at the quarterly review and another comes inSold at the review, at market price
BankruptcyStops trading (or moves to illiquid markets)Leaves on the day of the eventLast price, or zero if there is no recovery
Cash mergerStops trading when the deal closesLeaves on the day of the eventCollects the cash offer price
Stock mergerStops trading; its shares are exchangedLeaves on the day of the eventReceives acquirer shares according to the exchange ratio
Symbol changeKeeps trading under another tickerSame company, new tickerNothing: 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:

IPOadditionremovalsymbol changebankruptcymergerListedMemberBankruptAcquired
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:

IN [04]python
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"]]
OUT [04]Membership table by session
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   True

Always 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.

IN [05]python
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")
OUT [05]Point-in-time versus survivors
  point-in-time: 5.2% a year, 1 USD → 1.74 USD
      survivors: 11.0% a year, 1 USD → 3.13 USD

The 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:

IN [06]python
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)
OUT [06]The year before coming in and the year before leaving
Stocks: {'entran': 15, 'excluidos': 7}
        entran  excluidos
mean      27.1      -19.9
median    16.3      -12.7
Fig. — Return over the 250 sessions before joining the index, being dropped from it and going bankrupt

The 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:

IN [07]python
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)
OUT [07]The last year of the four bankruptcies
  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.18

In 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:

IN [08]python
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%}")
OUT [08]The exchange of ZXUR for ZXZN
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:

IN [09]python
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")
OUT [09]Sensitivity: bankruptcies at zero
Last price:         5.2% a year
Bankruptcies at 0:  4.2% a year

One 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:

IN [10]python
cierres.loc["2018-05-30":"2018-06-06", ["ZXUV", "ZXMA"]]
OUT [10]Two pieces of the same company
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.97

By 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.

IN [11]python
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())
OUT [11]Permanent identifier for symbol changes
Columns by ticker: 57 · by identifier: 55
Return on 2018-06-04: -1.17%
ZXMA history: 2015-01-02 to 2025-12-31

With 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.

Members by dateUniverse at tPermanent idPrices by idBacktestDelistings
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

  1. 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
IN [12]python
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")
OUT [12]Monthly rebalancing
Point-in-time monthly: 5.6% a year
Survivors monthly:     11.1% a year

The 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.

  1. 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
IN [13]python
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")
OUT [13]Buy and hold the 2015 list
1 USD → 2.11 USD, 7.1% a year

The 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.

  1. 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

  1. 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.

  2. 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.

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

  4. 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.

  5. 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.

Resources

Data