Survivorship bias in aandelen-data uitgelegd
Survivorship bias verbergt delisted en overgenomen tickers in datasets. Ontdek het gat in uw backtest en leer hoe u een volledig universum aan aandelen correct kunt meten.
Survivorship bias in aandelen-data is het gat dat ontstaat wanneer een bestand alleen de tickers bevat die vandaag de dag nog genoteerd staan. Elk bedrijf dat is geschrapt van de beurs (delisted), is overgenomen, van naam is veranderd of is opgeheven, ontbreekt geruisloos. Een backtest die over dat universum wordt uitgevoerd, meet een gefilterde steekproef uit het verleden in plaats van de markt zoals die er destijds uitzag. In het bestand lijkt niets mis te zijn. Elke rij bevat een reële koers; het probleem zijn de rijen die niemand heeft geschreven.
Wat is survivorship bias in aandelen-data?
Denk na over hoe een dataset van "alle Amerikaanse aandelen" wordt samengesteld. Iemand vraagt een provider om de lijst met symbolen en haalt vervolgens de koershistorie op voor elke naam op die lijst. Die lijst is per definitie actueel. Hij beantwoordt de vraag "wat wordt er nu verhandeld", en elk symbool dat stopte met handelen vóórdat het verzoek werd gedaan, ontbreekt in het antwoord, samen met de volledige historie ervan.
De term is afkomstig van het werk van Abraham Wald uit 1943 over terugkerende bommenwerpers: de bepantsering moest komen op de plekken waar de teruggekeerde vliegtuigen geen gaten vertoonden, aangezien de vliegtuigen die op die plekken werden geraakt, nooit terugkwamen om gemeten te worden. Een aandelendatabase die is opgebouwd uit de huidige noteringen heeft dezelfde vorm. De steekproef die u ziet, is de steekproef die het heeft overleefd, en het lidmaatschap ervan wordt bepaald door precies de uitkomst die een backtest probeert te meten.
Hoeveel tickers verdwijnen er elk jaar?
Begin bij de omvang van het gat. Het onderstaande overzicht groepeert elk symbool op de dagelijkse aandelenband (equity tape) op het kalenderjaar van de laatste koersbalk, waarbij alleen symbolen worden behouden die al vóór januari 2026 waren verdwenen.
De exacte SQL achter elk getal
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 drukten 1283 symbolen een laatste dagelijkse koersbalk af en kwamen ze nooit meer terug. Dit zijn geen lege hulzen van een week oud: die groep had vanaf 2015 gemiddeld 999 dagelijkse koersbalken. Over de 10 jaar in het overzicht schommelt het aantal, en geen enkel jaar zit in de buurt van nul. Symbolen verdwijnen om alledaagse redenen: een overname wordt afgerond, een fonds wordt opgeheven, een fusie voegt de ene notering samen met de andere, of er wordt niet langer voldaan aan de noteringsnormen.
Zie de bias verschijnen in een script van tien regels
De rekenkunde is makkelijker te zien dan te beschrijven. Het onderstaande script schrijft twee kleine CSV-bestanden: één universum met alleen overlevers, en hetzelfde universum met de namen die stopten met handelen weer toegevoegd. Het berekent het gemiddelde van elk bestand en drukt het verschil af. Het gebruikt alleen de standaardbibliotheek van Python, dus er hoeft niets geïnstalleerd te worden.
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
De zeven rendementscijfers in dat script zijn verzonnen, geen marktdata. Ze dienen om het mechanisme zichtbaar te maken. 'Survivors only' komt uit op een gemiddelde van +32,5%, het volledige universum op +0,9%, en het verschil van 31,6 procentpunten is de bias. Niets in het 'survivors-only'-bestand is onjuist. Elk van die vier namen behaalde daadwerkelijk het genoemde rendement. Het bestand vermeldt simpelweg de drie namen niet die het gemiddelde omlaag zouden hebben getrokken.
Dezelfde meting bij echte cohorten
Voer nu dezelfde berekening uit op de marktdata. Het onderstaande overzicht bouwt een startuniversum in januari van elk jaar van 2016 tot en met 2022, waarbij symbolen worden behouden die de maand openden boven de vijf dollar met een gemiddeld dagelijks volume van meer dan 250.000 aandelen, en volgt vervolgens elk van hen tot de meest recente slotkoers. Namen die stopten met handelen worden vastgehouden op hun laatst gedrukte koers in plaats van verwijderd. De kolom 'survivors' middelt alleen de symbolen die nu nog koersbalken afdrukken. De kolom 'full universe' middelt iedereen die er aan het begin was.
De exacte SQL achter elk getal
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_startVoor het 2016-cohort had 968 van de 2728 namen de markt verlaten tegen de tijd dat deze pagina werd opgesteld. Het middelen van alleen de overlevers geeft 212%. Het middelen van het hele cohort geeft 149.3%. De kolom 'gap' bevat het verschil tussen die twee waarden: 62.7 punten voor dat cohort, en 9.1 punten voor het 2022-cohort, dat veel minder tijd heeft gehad om leden te verliezen. Dit zijn totaalrendementen over periodes van verschillende lengtes, dus lees de kolom 'gap' verticaal in plaats van niveaus tussen rijen te vergelijken. Dezelfde vraag over het universum ligt ten grondslag aan hoe maandelijkse rendementen worden gemeten en aan elk gelijkgewogen gemiddelde.
Waar de ontbrekende namen eindigden
Een delisting is niet automatisch een totale afschrijving. Het onderstaande overzicht neemt één startuniversum, januari 2019, en sorteert het op wat er daarna gebeurde.
De exacte SQL achter elk getal
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_labelDe Still trading-groep bevat 1954 namen, met een gemiddelde van 139.8% vanaf de instapprijs van januari 2019, waarbij 72.1% van hen boven hun startniveau staat. Lees de 9 rijen en de exits zien er gemengd uit. Een bedrijf dat met een premie wordt gekocht, kan de markt verlaten op een sterke laatste koers, terwijl een bedrijf dat wegkwijnt richting een delisting-bericht, vertrekt op een zwakke koers. Beide ontbreken in een 'survivors-only'-bestand. Voeg ze toe en het gemiddelde verschuift.
Survivorship bias versus look-ahead bias
Deze twee worden vaak in één adem genoemd, maar ze verstoren een backtest op verschillende punten. Look-ahead bias is een timingprobleem: de test gebruikt een feit op een moment dat niemand het nog wist, zoals een herziene winstcijfer of een aanpassing die maanden later werd doorgevoerd. Survivorship bias is een lidmaatschapsprobleem: de lijst die de test verhandelt, is de lijst die nu bestaat, niet de lijst die destijds bestond. Een backtest kan qua timing vlekkeloos zijn en toch een universum verhandelen dat met terugwerkende kracht is samengesteld. Koersaanpassingen bevatten een soortgelijke valkuil, aangezien split-adjusted koershistorie het niveau van elke koersbalk vóór de splitdatum herschrijft.
Hoe survivorship bias in een backtest te corrigeren
- Bouw het universum per datum opnieuw op. 'Point-in-time'-lidmaatschap betekent dat de lijst wordt gereconstrueerd voor de dag waarop de test zich bevindt, op basis van gegevens die op die dag bestonden. De test bevat dan namen die op dat moment actief waren, inclusief de namen die later verdwenen.
- Houd een 'security master' bij. Een security master koppelt identificatiegegevens over tijd: welk symbool verwees naar welk bedrijf tussen welke data, en wat verving het na een fusie of naamswijziging. Koersbestanden zijn gebaseerd op symbolen. Bedrijven niet.
- Sluit geschrapte namen af op hun laatste reële koers. Wanneer een naam de markt verlaat, wordt de positie afgesloten op de laatste koers in plaats van dat deze uit het gemiddelde verdwijnt. Verwijdering is wat het gat in de bovenstaande overzichten opent. Een reproduceerbare backtest legt die regel vast in de code naast het universum.
- Lees de documentatie van de provider over 'dead symbols'. Leveranciers die dit serieus nemen, leggen het vast. Massive stelt geschrapte symbolen beschikbaar via hun tickers-endpoint met een 'active'-vlag, en houdt symboolwijzigingen, delistings, fusies en overnames bij als records in een apart 'ticker events'-endpoint.
"Wij wijzigen de data niet en voegen symboolwijzigingen niet samen tot één geaggregeerde reeks."
Massive knowledge base, "Hoe gaat Massive om met tickerwijzigingen en overnames?", gelezen in augustus 2026.
Als de documentatie van een provider niets zegt over geschrapte symbolen, behandel de download dan als 'survivors only' totdat u dit heeft gecontroleerd. De dekking varieert sterk tussen bronnen: zie onze notities over gratis aandelenmarkt-API's.
De valkuil van de gerecyclede ticker
Symbolen komen weer in omloop nadat een notering eindigt. Het onderstaande overzicht telt symbolen die ten minste 200 dagelijkse koersbalken afdrukten, ten minste 250 kalenderdagen stil waren, en vervolgens vanaf 2022 weer verschenen als een gloednieuwe notering.
De exacte SQL achter elk getal
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 kwamen 16 symbolen op deze manier terug, na een gemiddelde stilte van 3247 kalenderdagen. De langste stille periode in die rij duurde 5734 dagen. Een koersbestand dat alleen op het symbool is gebaseerd, niet de historie van het ene bedrijf aan die van het andere, en de koppeling ziet er de hele weg schoon uit. Dit is het geval dat een 'security master' opvangt en een kale ticker-kolom niet.
Datatoelichtingen en definities
Een symbool telt als 'nog steeds verhandeld' wanneer de laatste dagelijkse koersbalk binnen 45 kalenderdagen valt van de dag waarop deze pagina werd gegenereerd. Die buffer dekt de vertraging van één tot twee dagen aan het begin van de marktdata en illiquide namen die sessies overslaan.
Rendementen zijn totale koersrendementen vanaf de slotkoers van de instapmaand tot de laatste beschikbare slotkoers voor dat symbool, zonder dividenden en zonder annualisering. Eerdere cohorten hebben langer de tijd gehad om te renderen, dus de niveaus zijn niet vergelijkbaar tussen rijen. Het verschil tussen de twee gemiddelden wel.
Instapuniversa behouden symbolen die geprijsd zijn op vijf dollar of meer met een gemiddeld dagelijks volume van 250.000 aandelen of meer tijdens de instapmaand. De dagelijkse marktdata bevat fondsen, units en warrants naast operationele bedrijven, en niets hier scheidt ze, dus een fondsliquidatie telt mee naast een bedrijfsmatige delisting. Symbolen met een bekend identiteitsconflict in leveranciersfeeds worden in SQL uit elk overzicht uitgesloten in plaats van achteraf gefilterd.
Veelgestelde vragen
Wat is survivorship bias in een aandelen-backtest?
Het is de fout die achterblijft wanneer het verhandelbare universum wordt opgebouwd uit symbolen die vandaag de dag nog bestaan. Bedrijven die zijn geschrapt of overgenomen, komen nooit in de test terecht, waardoor de strategie alleen namen krijgt aangeboden die het tot het heden hebben gered.
Hoe weet ik of mijn dataset survivorship bias bevat?
Neem het universum dat uw bestand claimt te dekken voor een datum van enkele jaren geleden en controleer de laatste koersbalk van elk symbool. Als elke naam in een lijst uit 2016 vandaag de dag nog koersen afdrukt, is de lijst opgebouwd uit huidige noteringen en zaten de verdwenen namen er nooit in.
Zorgt survivorship bias er altijd voor dat een backtest er beter uitziet?
Nee. De richting hangt af van hoe namen de markt verlieten. Een overname kan eindigen met een premie, en het schrappen van die namen zorgt voor een onderschatting van een cohort, terwijl een langzame neergang richting een delisting-bericht het tegenovergestelde doet. De bovenstaande overzichten meten het netto-effect van beide op echte cohorten.
Wat is een 'point-in-time'-universum?
Het is een ledenlijst die wordt gereconstrueerd per datum in de test, waarbij alleen records worden gebruikt die op die datum bestonden. Het bevat namen die destijds verhandelbaar waren en later verdwenen, en sluit namen uit die nog niet genoteerd waren.
Is survivorship bias hetzelfde als look-ahead bias?
Nee. Look-ahead bias gaat over timing: de test gebruikt informatie voordat iemand die had. Survivorship bias gaat over lidmaatschap: de test verhandelt een lijst die met terugwerkende kracht is samengesteld. Een backtest kan schoon zijn op het ene punt en gebrekkig op het andere.
Elk overzicht hier bevat de SQL die het heeft geproduceerd. Wijzig de instapmaand of versoepel de liquiditeitsfilter en voer deze uit op de Strasmore-terminal om te zien hoeveel van het gat overblijft bij uw eigen universumregels.