Survivorship Bias in Stock Data, Explained
Survivorship bias hides delisted and acquired tickers from most stock datasets. See the gap it opens in a backtest, and how to measure a full universe.
Survivorship bias in stock data is the gap that opens when a file holds only the tickers that are still listed today. Every company that was delisted, acquired, renamed, or wound down is silently absent, and a backtest run across that universe measures a filtered sample of history rather than the market as it stood. Nothing in the file looks broken. Every row carries a real price, and the trouble is the rows nobody wrote.
What is survivorship bias in stock data?
Think about how an "all US stocks" dataset gets assembled. Something asks a provider for the list of symbols, then pulls price history for each name on the list. That list is current by construction. It answers the question "what trades now", and every symbol that stopped trading before the request was made is missing from the answer, along with its entire history.
The term comes from Abraham Wald's 1943 work on returning bombers: the armour belonged where the surviving aircraft showed no holes, since the planes hit in those places never came back to be measured. A stock database built from today's listings has the same shape. The sample you can see is the sample that made it home, and membership in it is decided by the very outcome a backtest is trying to measure.
How many tickers go dark each year?
Start with the size of the hole. The panel below groups every symbol on the daily equity tape by the calendar year of its final bar, keeping only symbols that had already fallen silent before January 2026.
The exact SQL behind every number
SELECT
toString(toYear(last_bar)) AS year,
count() AS gone_dark_count,
round(avg(bars_on_tape)) AS avg_bars_on_tape
FROM
(
SELECT
ticker,
max(date) AS last_bar,
count() AS bars_on_tape
FROM global_markets.stocks_daily_aggs
WHERE date >= '2015-01-01'
AND ticker NOT IN ('SPCX')
GROUP BY ticker
HAVING max(date) >= toDate('2016-01-01')
AND max(date) < toDate('2026-01-01')
)
GROUP BY year
ORDER BY yearIn 2025, 1283 symbols printed a last daily bar and never came back. These are not week-old shells: that group averaged 999 daily bars from 2015 onward. Across the 10 years in the panel the count moves around, and no year sits near zero. Symbols leave for ordinary reasons: an acquisition closes, a fund winds down, a merger folds one listing into another, a listing standard stops being met.
Watch the bias appear in a ten line script
The arithmetic is easier to see than to describe. The script below writes two small CSV files: one survivors-only universe, and the same universe with the names that stopped trading added back. It averages each file and prints the difference. It uses only the Python standard library, so there is nothing to install.
python3 <<'PY'
import csv, statistics
survivors = [("AAA", 0.42), ("BBB", 0.18), ("CCC", 0.63), ("DDD", 0.07)]
delisted = [("EEE", -1.00), ("FFF", -0.55), ("GGG", 0.31)]
def write_csv(path, rows):
with open(path, "w", newline="") as f:
w = csv.writer(f)
w.writerow(["ticker", "total_return"])
w.writerows(rows)
def mean_return(path):
with open(path, newline="") as f:
return statistics.mean(float(r["total_return"]) for r in csv.DictReader(f))
write_csv("survivors_only.csv", survivors)
write_csv("full_universe.csv", survivors + delisted)
a = mean_return("survivors_only.csv")
b = mean_return("full_universe.csv")
print(f"survivors only: {a:+.1%} across {len(survivors)} names")
print(f"full universe: {b:+.1%} across {len(survivors) + len(delisted)} names")
print(f"survivorship bias: {a - b:+.1%}")
PY
The seven return figures in that script are invented, not market data. They exist to make the mechanism visible. Survivors only averages +32.5%, the full universe averages +0.9%, and the difference of 31.6 points is the bias. Nothing in the survivors-only file is false. Each of those four names really did return what it says. The file simply never mentions the three that would have pulled the average down.
The same measurement on real cohorts
Now run the same calculation on the tape. The panel below builds a starting universe in January of each year from 2016 through 2022, keeping symbols that opened the month above $5 with average daily volume above 250,000 shares, then follows every one of them to its most recent close. Names that stopped trading are held at their final printed price rather than dropped. The survivors column averages only the symbols still printing bars now. The full universe column averages everyone who was there at the start.
The exact SQL behind every number
WITH
entry AS
(
SELECT
ticker,
toYear(date) AS cohort_start,
argMin(toFloat64(close), date) AS entry_close
FROM global_markets.stocks_daily_aggs
WHERE toMonth(date) = 1
AND date BETWEEN '2016-01-01' AND '2022-01-31'
AND ticker NOT IN ('SPCX')
GROUP BY ticker, cohort_start
HAVING argMin(toFloat64(close), date) >= 5
AND avg(volume) >= 250000
),
outcome AS
(
SELECT
ticker,
argMax(toFloat64(close), date) AS final_close,
max(date) AS last_bar
FROM global_markets.stocks_daily_aggs
WHERE date >= '2016-01-01'
GROUP BY ticker
)
SELECT
toString(e.cohort_start) AS cohort_year,
count() AS cohort_size,
countIf(o.last_bar < today() - 45) AS gone_count,
round(100 * avgIf(o.final_close / e.entry_close - 1, o.last_bar >= today() - 45), 1) AS survivors_only_pct,
round(100 * avg(o.final_close / e.entry_close - 1), 1) AS full_universe_pct,
round(100 * (avgIf(o.final_close / e.entry_close - 1, o.last_bar >= today() - 45)
- avg(o.final_close / e.entry_close - 1)), 1) AS gap_pct
FROM entry AS e
INNER JOIN outcome AS o ON o.ticker = e.ticker
GROUP BY e.cohort_start
HAVING countIf(o.last_bar >= today() - 45) > 0
ORDER BY e.cohort_startFor the 2016 cohort, 968 of 2728 names had left the tape by the time this page was built. Averaging only the survivors gives 212%. Averaging the whole cohort gives 149.3%. The gap column holds the distance between those two readings: 62.7 points for that cohort, and 9.1 points for the 2022 cohort, which has had far less time to lose members. These are total returns over windows of different lengths, so read down the gap column rather than comparing levels across rows. The same universe question sits underneath how monthly returns are measured and underneath every equal-weight average.
Where the missing names ended up
A delisting is not automatically a wipeout. The panel below takes one starting universe, January 2019, and sorts it by what happened next.
The exact SQL behind every number
WITH
entry AS
(
SELECT
ticker,
argMin(toFloat64(close), date) AS entry_close
FROM global_markets.stocks_daily_aggs
WHERE date BETWEEN '2019-01-01' AND '2019-01-31'
AND ticker NOT IN ('SPCX')
GROUP BY ticker
HAVING argMin(toFloat64(close), date) >= 5
AND avg(volume) >= 250000
),
outcome AS
(
SELECT
ticker,
argMax(toFloat64(close), date) AS final_close,
max(date) AS last_bar
FROM global_markets.stocks_daily_aggs
WHERE date >= '2019-01-01'
GROUP BY ticker
)
SELECT
if(o.last_bar >= today() - 45,
'Still trading',
concat('Last bar in ', toString(toYear(o.last_bar)))) AS outcome_label,
count() AS cohort_size,
round(100 * avg(o.final_close / e.entry_close - 1), 1) AS avg_return_pct,
round(100 * countIf(o.final_close > e.entry_close) / count(), 1) AS share_above_entry_pct
FROM entry AS e
INNER JOIN outcome AS o ON o.ticker = e.ticker
GROUP BY outcome_label
ORDER BY outcome_labelThe Still trading group holds 1954 names, averaging 139.8% from the January 2019 entry price, with 72.1% of them above where they started. Read the 9 rows and the exits look mixed. A company bought at a premium can leave the tape on a strong final print, while one that fades toward a delisting notice leaves on a weak one. Both are missing from a survivors-only file. Put them back and the average moves.
Survivorship bias vs look ahead bias
These two get filed together, and they break a backtest in different places. Look ahead bias is a timing problem: the test uses a fact at a moment when nobody had it yet, such as a restated earnings figure or an adjustment that was applied months later. Survivorship bias is a membership problem: the roster the test trades is the roster that exists now, not the roster that existed then. A backtest can be spotless on timing and still trade a universe assembled with hindsight. Price adjustments carry a related trap, since split adjusted price history rewrites the level of every bar before the split date.
How to fix survivorship bias in a backtest
- Rebuild the universe as of each date. Point in time membership means the roster is reconstructed for the day the test is standing on, from records that existed on that day. The test then holds names that were live then, including the ones that later disappeared.
- Keep a security master. A security master maps identifiers over time: which symbol pointed at which company between which dates, and what replaced it after a merger or a rename. Price files key on symbols. Companies do not.
- Close delisted names at their last real price. When a name leaves the tape, the position exits at its final print instead of vanishing from the average. Deletion is what opens the gap in the panels above. A reproducible backtest pins that rule in code next to the universe.
- Read the provider's documentation on dead symbols. Vendors that take this seriously write it down. Massive exposes delisted symbols through its tickers endpoint with an active flag, and carries symbol changes, delistings, mergers and acquisitions as records in a separate ticker events endpoint.
"We do not change the data or concatenate ticker changes into a single aggregate series."
Massive knowledge base, "How does Massive handle ticker changes and acquisitions?", read August 2026.
If a provider's documentation says nothing about delisted symbols, treat the download as survivors only until you have checked. Coverage varies widely between sources: see our notes on free stock market data APIs.
The recycled ticker trap
Symbols go back into circulation after a listing ends. The panel below counts symbols that printed at least 200 daily bars, went quiet for at least 250 calendar days, then appeared again as a brand new listing from 2022 onward.
The exact SQL behind every number
SELECT
toString(toYear(relisted_on)) AS year,
count() AS relisting_count,
round(avg(dark_stretch)) AS avg_dark_stretch,
max(dark_stretch) AS longest_dark_stretch
FROM
(
SELECT
ticker,
any(relist_date) AS relisted_on,
dateDiff('day', max(date), any(relist_date)) AS dark_stretch
FROM
(
SELECT
d.ticker AS ticker,
d.date AS date,
i.relist_date AS relist_date
FROM global_markets.stocks_daily_aggs AS d
INNER JOIN
(
SELECT
ticker,
min(toDate(listing_date)) AS relist_date
FROM global_markets.stocks_ipos
WHERE toDate(listing_date) BETWEEN '2022-01-01' AND today()
AND ticker NOT IN ('SPCX')
GROUP BY ticker
) AS i ON i.ticker = d.ticker
WHERE d.date < i.relist_date
)
GROUP BY ticker
HAVING dateDiff('day', max(date), any(relist_date)) >= 250
AND count() >= 200
)
GROUP BY year
ORDER BY yearIn 2022, 16 symbols came back this way, after an average silence of 3247 calendar days. The longest quiet stretch in that row ran 5734 days. A price file keyed on the symbol alone staples one company's history onto another's, and the join looks clean the whole way through. This is the case a security master catches and a bare ticker column cannot.
Data notes and definitions
A symbol counts as still trading when its latest daily bar falls within 45 calendar days of the day this page was generated. That buffer covers the one to two day lag at the front of the tape and thin names that skip sessions.
Returns are total price returns from the January entry close to the last close available for that symbol, with no dividends and no annualising. Earlier cohorts have had longer to compound, so the levels are not comparable across rows. The gap between the two averages is.
Entry universes keep symbols priced at $5 or more with average daily volume of 250,000 shares or more during the entry month. The daily tape carries funds, units and warrants next to operating companies, and nothing here separates them, so a fund closure counts alongside a corporate delisting. Symbols with a known identity conflict in vendor feeds are excluded in SQL from every panel rather than filtered out afterwards.
FAQ
What is survivorship bias in a stock backtest?
It is the error left behind when the tradable universe is built from symbols that still exist today. Companies that were delisted or acquired never enter the test, so the strategy is only ever offered names that made it to the present.
How do I know if my dataset has survivorship bias?
Take the universe your file claims to cover for a date several years back and check each symbol's final bar. If every name in a 2016 list is still printing prices today, the list was built from current listings and the dead names were never in it.
Does survivorship bias always make a backtest look better?
No. The direction depends on how names left the tape. An acquisition can close out at a premium, and dropping those names understates a cohort, while a long fade into a delisting notice does the opposite. The panels above measure the net of both on real cohorts.
What is a point in time universe?
It is a membership list reconstructed as of each date in the test, using only records that existed on that date. It includes names that were tradable then and later disappeared, and excludes names that had not listed yet.
Is survivorship bias the same as look ahead bias?
No. Look ahead bias is about timing: the test uses information before anyone had it. Survivorship bias is about membership: the test trades a roster assembled with hindsight. A backtest can be clean on one and broken on the other.
Every panel here carries the SQL that produced it. Change the entry month or loosen the liquidity filter, then run it on the Strasmore terminal to see how much of the gap survives your own universe rules.