---
title: "1.4.4 Survivorship bias and point-in-time universes"
description: "Index members on each date, bankruptcies, mergers and symbol changes: a backtest returns 5.2% a year point-in-time and 11.0% with survivors."
url: "https://pythonparatrading.com/en/python-trading-course/level-1/survivorship-bias-and-point-in-time-data/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-1/survivorship-bias-and-point-in-time-data.png"
---

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

- Course: [Level 1: Financial data, markets and analysis](https://pythonparatrading.com/en/python-trading-course/level-1/)
- Module: 1.4 · Financial data engineering
- Duration: 55 min
- Prerequisites: [1.4.3 · Adjusting for splits and dividends: adjusted and unadjusted prices](https://pythonparatrading.com/en/python-trading-course/level-1/adjusting-prices-for-splits-and-dividends/), [0.5.3 · Selection, filtering, groupby, merge, pivot, resample and moving windows](https://pythonparatrading.com/en/python-trading-course/level-0/pandas-groupby-merge-resample-rolling/)

## By the end of this lesson

- Rebuild the members of an index on any date from its additions and removals, and run a backtest that uses only those known at each moment.
- Measure survivorship bias by comparing the point-in-time universe with today's survivors, and explain where the difference comes from.
- Handle bankruptcies, cash mergers and stock mergers: what return the last day has and what leaving it out implies.
- Follow a company through a symbol change with a permanent identifier.

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

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

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

```python
print(eventos["evento"].value_counts().to_dict())
cols = ["fecha", "ticker", "evento", "ticker_nuevo"]
eventos[cols].iloc[[1, 3, 5, 6]]
```
```output
{'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.* Timeline with four rows. Seven exclusions spread between 2015 and 2025 (ZXUE, ZXOX, ZXBA, ZXAO, ZXHX, ZXCD, ZXKB); four bankruptcies, highlighted (ZXIR in 2016, ZXTW and ZXFA in 2020, ZXSW in 2023); four mergers (ZXRQ, ZXUR, ZXOJ, ZXBY); and two ticker changes (ZXUV in 2018, ZXLU in 2022).

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:

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

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

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

```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)
```
```output
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.* Three rows of dots. The 15 stocks that join had gained 27.1% on average (highlighted), one above 120%. The 7 dropped stocks had lost 19.9% on average. The 4 bankruptcies lost between 95% and 98% in their last year.

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:

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

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

The bias does not come only from the losers. The three companies acquired for cash (ZXRQ, ZXOJ, ZXBY) are also missing
from today's list, and an acquisition usually comes with a premium: they are good exits. In this universe they weigh
less than the bankruptcies and removals, but in a real index the sign of the bias cannot be assumed: it is measured.

</aside>

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

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

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

```python
cierres.loc["2018-05-30":"2018-06-06", ["ZXUV", "ZXMA"]]
```
```output
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.

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

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

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

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

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

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

## Self-assessment

1. A backtest uses the 40 current members of an index with their whole history since 2015. What mistake does it make?

   - A. None, if the prices are adjusted
   - B. Only a performance one: it takes longer to compute
   - C. It uses too many stocks
   - 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

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

   - A. They fell sharply
   - B. The same as the index
   - C. They rose sharply: that is why they came in (more market cap)
   - D. There is no way to know

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

   - A. That the position was sold at 0.37
   - B. That the position is worth zero
   - C. That the stock is still trading
   - D. Nothing

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

   - A. Nothing: the provider already handles it
   - 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
   - C. The company is duplicated
   - D. The backtest fails with an error

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

   - A. Yes: the bias comes from daily rebalancing
   - B. It reverses: the survivors return less
   - C. It depends on the costs
   - D. No: the return barely changes (11.1% versus 11.0%); the bias comes from the list of stocks, not from the frequency

   *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

### Research

- [Introduction to Quant Research](https://pythonparatrading.com/en/quant-research-2/) · Research-0023 · 8 min
- [Big Data Management 4 - Using the Datalake in Zipline](https://pythonparatrading.com/en/backtesting-with-zipline-and-arcticdb/) · Research-0022 · 5 min

### Documentation

- [CRSP US Stock Databases (PERMNO and PERMCO permanent identifiers)](https://www.crsp.org/research/crsp-us-stock-databases/) · crsp.org
- [Tyler Shumway, “The Delisting Bias in CRSP Data”, Journal of Finance 52(1), 1997](https://ideas.repec.org/a/bla/jfinan/v52y1997i1p327-40.html) · ideas.repec.org

### Data

- [Level 1 data: index members (CSV)](https://pythonparatrading.com/curso/datos/nivel-1/universo_miembros.csv) · Download · universo_miembros.csv
- [Level 1 data: universe events (CSV)](https://pythonparatrading.com/curso/datos/nivel-1/universo_eventos.csv) · Download · universo_eventos.csv
- [Level 1 data: universe prices (Parquet)](https://pythonparatrading.com/curso/datos/nivel-1/universo_precios.parquet) · Download · universo_precios.parquet

Previous lesson: [1.4.3 · Adjusting for splits and dividends: adjusted and unadjusted prices](https://pythonparatrading.com/en/python-trading-course/level-1/adjusting-prices-for-splits-and-dividends/)

Next lesson: [1.4.5 · Building bars: time, tick, volume, dollar and imbalance](https://pythonparatrading.com/en/python-trading-course/level-1/time-tick-volume-and-dollar-bars/)
