Strasmore Research
Deep Dives · Matt ConnorBy Matt Connor ·

Błąd przeżywalności w danych giełdowych wyjaśnienie

Błąd przeżywalności zniekształca wyniki backtestów poprzez pomijanie spółek wycofanych z obrotu. Analiza wyjaśnia wpływ brakujących danych na rzetelność historycznych analiz.

Błąd przeżywalności (survivorship bias) w danych giełdowych to luka powstająca, gdy zbiór danych zawiera wyłącznie tickery, które są notowane do dnia dzisiejszego. Każda spółka, która została wycofana z obrotu, przejęta, zmieniła nazwę lub zakończyła działalność, jest pominięta, a backtest przeprowadzony na takim zbiorze mierzy przefiltrowaną próbkę historii, a nie rynek w jego ówczesnym kształcie. Plik nie wykazuje żadnych błędów technicznych. Każdy wiersz zawiera rzeczywistą cenę, a problemem są wiersze, których nikt nie uwzględnił.

Czym jest błąd przeżywalności w danych giełdowych?

Należy rozważyć sposób tworzenia zbioru danych obejmującego „wszystkie akcje z USA”. System pobiera od dostawcy listę symboli, a następnie historię cen dla każdej nazwy z tej listy. Lista ta jest z założenia aktualna. Odpowiada na pytanie „co jest obecnie przedmiotem obrotu”, a każdy symbol, który przestał być notowany przed momentem zapytania, znika z odpowiedzi wraz z całą swoją historią.

Termin ten wywodzi się z pracy Abrahama Walda z 1943 roku dotyczącej powracających bombowców: opancerzenie powinno znajdować się tam, gdzie ocalałe maszyny nie miały przestrzelin, ponieważ samoloty trafione w te miejsca nigdy nie wróciły, by można było je zbadać. Baza danych akcji zbudowana na podstawie dzisiejszych notowań ma ten sam charakter. Widoczna próbka to ta, która „wróciła do bazy”, a przynależność do niej jest determinowana przez wynik, który backtest próbuje zmierzyć.

Ile tickerów znika z rynku każdego roku?

Należy zacząć od skali zjawiska. Poniższy panel grupuje każdy symbol z dziennych notowań akcji według roku kalendarzowego, w którym odnotowano jego ostatnią sesję, uwzględniając tylko symbole, które przestały być notowane przed styczniem 2026 roku.

ZapytanieLiczba wycofanych symboli z obrotu według roku
Dokładny kod SQL dla każdej liczby
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 year
Run this yourself

W 2025 roku 1283 symboli odnotowało ostatnią sesję i nigdy nie powróciło do obrotu. Nie są to efemeryczne spółki: grupa ta średnio liczyła 999 sesji od 2015 roku. W ciągu 10 lat ujętych w panelu liczba ta ulega zmianom, przy czym w żadnym roku nie zbliża się do zera. Symbole znikają z typowych powodów: finalizacja przejęcia, likwidacja funduszu, fuzja łącząca jeden podmiot z drugim lub niespełnienie wymogów notowań.

Obserwacja błędu w dziesięcioliniowym skrypcie

Arytmetykę łatwiej pokazać niż opisać. Poniższy skrypt tworzy dwa małe pliki CSV: jeden zawierający tylko spółki, które przetrwały, oraz drugi, obejmujący ten sam zbiór z uwzględnieniem nazw, które przestały być notowane. Skrypt oblicza średnią dla każdego pliku i drukuje różnicę. Wykorzystuje wyłącznie standardową bibliotekę języka Python, więc nie wymaga instalacji dodatkowych komponentów.

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

Siedem liczb dotyczących stóp zwrotu w skrypcie jest przykładowych, a nie rynkowych. Służą one jedynie zobrazowaniu mechanizmu. Średnia dla spółek, które przetrwały, wynosi +32,5%, dla pełnego uniwersum +0,9%, a różnica 31,6 punktu procentowego stanowi błąd przeżywalności. Żadna informacja w pliku „survivors only” nie jest fałszywa. Każda z czterech nazw faktycznie wygenerowała podany zwrot. Plik po prostu nie wspomina o trzech, które obniżyłyby średnią.

Ten sam pomiar na rzeczywistych kohortach

Teraz należy przeprowadzić ten sam obliczenie na danych rynkowych. Poniższy panel buduje początkowe uniwersum w styczniu każdego roku od 2016 do 2022, zachowując symbole, których cena na otwarciu miesiąca przekraczała 5 USD, a średni dzienny wolumen wynosił powyżej 250 000 akcji, a następnie śledzi każdy z nich do ostatniego zamknięcia. Nazwy, które przestały być notowane, są utrzymywane przy ostatniej odnotowanej cenie, zamiast być usuwane. Kolumna „survivors” uśrednia tylko symbole, które są nadal notowane. Kolumna „full universe” uśrednia wszystkich, którzy byli obecni na początku.

ZapytanieŚrednia dla spółek przetrwałych vs średnia dla całej kohorty według roku bazowego
Dokładny kod SQL dla każdej liczby
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_start
Run this yourself

Dla kohorty 2016, 968 z 2728 nazw zniknęło z notowań do momentu powstania tej strony. Uśrednienie tylko tych, które przetrwały, daje 212%. Uśrednienie całej kohorty daje 149.3%. Kolumna „gap” zawiera różnicę między tymi dwoma odczytami: 62.7 punktu dla tej kohorty i 9.1 punktu dla kohorty 2022, która miała znacznie mniej czasu na utratę członków. Są to całkowite stopy zwrotu w różnych przedziałach czasowych, dlatego należy analizować kolumnę „gap” w dół, zamiast porównywać poziomy między wierszami. To samo pytanie o uniwersum leży u podstaw sposobu mierzenia miesięcznych stóp zwrotu oraz każdej średniej ważonej równo.

Gdzie trafiły brakujące nazwy

Wycofanie z giełdy nie zawsze oznacza całkowitą stratę. Poniższy panel bierze jedno początkowe uniwersum, styczeń 2019 roku, i sortuje je według dalszych losów.

ZapytanieKohorta ze stycznia 2019 r. w podziale na statusy spółek
Dokładny kod SQL dla każdej liczby
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_label
Run this yourself

Grupa Still trading obejmuje 1954 nazw, ze średnią 139.8% od ceny wejścia ze stycznia 2019 roku, przy czym 72.1% z nich znajduje się powyżej poziomu początkowego. Wystarczy przeanalizować wiersze 9, aby zauważyć, że wyjścia z rynku są zróżnicowane. Spółka przejęta z premią może zniknąć z notowań przy wysokim kursie, podczas gdy spółka, która zmierza w stronę wycofania z obrotu, kończy przy niskim. Obie są pominięte w pliku „survivors-only”. Po ich przywróceniu średnia ulega zmianie.

Błąd przeżywalności a błąd patrzenia w przyszłość (look ahead bias)

Te dwa błędy są często zestawiane razem, choć psują backtest w różnych miejscach. Błąd patrzenia w przyszłość to problem czasowy: test wykorzystuje fakt w momencie, gdy nikt jeszcze nim nie dysponował, np. skorygowane wyniki finansowe lub korektę zastosowaną miesiące później. Błąd przeżywalności to problem przynależności: lista, na której test przeprowadza transakcje, to lista istniejąca obecnie, a nie ta, która istniała w przeszłości. Backtest może być bezbłędny pod względem czasowym, a mimo to operować na uniwersum zbudowanym z perspektywy czasu. Korekty cen niosą ze sobą podobną pułapkę, ponieważ historia cen skorygowana o splity nadpisuje poziom każdego słupka sprzed daty podziału akcji.

Jak naprawić błąd przeżywalności w backteście

  1. Odbuduj uniwersum na każdą datę. Przynależność typu „point in time” oznacza, że lista jest rekonstruowana na dzień, w którym znajduje się test, na podstawie rekordów istniejących w tamtym dniu. Test uwzględnia wtedy nazwy, które były wówczas aktywne, w tym te, które później zniknęły.
  2. Prowadź główny rejestr papierów wartościowych (security master). Rejestr ten mapuje identyfikatory w czasie: który symbol wskazywał na którą spółkę między jakimi datami oraz co zastąpiło ją po fuzji lub zmianie nazwy. Pliki cenowe opierają się na symbolach. Spółki – nie.
  3. Zamykaj wycofane nazwy po ich ostatniej rzeczywistej cenie. Gdy nazwa znika z notowań, pozycja powinna zostać zamknięta po ostatnim kursie, zamiast znikać ze średniej. Usunięcie jest tym, co otwiera lukę w powyższych panelach. Powtarzalny backtest przypisuje tę zasadę w kodzie obok uniwersum.
  4. Przeczytaj dokumentację dostawcy dotyczącą martwych symboli. Dostawcy, którzy traktują to poważnie, opisują to w dokumentacji. Massive udostępnia wycofane symbole poprzez endpoint tickerów z flagą aktywności oraz przechowuje zmiany symboli, wycofania, fuzje i przejęcia jako rekordy w osobnym endpoincie zdarzeń tickerów.
„Nie zmieniamy danych ani nie łączymy zmian tickerów w jedną zagregowaną serię”.
Baza wiedzy Massive, „Jak Massive obsługuje zmiany tickerów i przejęcia?”, odczytano w sierpniu 2026 r.

Jeśli dokumentacja dostawcy nie wspomina o wycofanych symbolach, traktuj pobrane dane jako „survivors only”, dopóki tego nie sprawdzisz. Zakres danych różni się znacząco między źródłami: zobacz nasze uwagi na temat darmowych API z danymi giełdowymi.

Pułapka recyklingu tickerów

Symbole wracają do obiegu po zakończeniu notowań. Poniższy panel zlicza symbole, które odnotowały co najmniej 200 dziennych sesji, pozostawały nieaktywne przez co najmniej 250 dni kalendarzowych, a następnie pojawiły się ponownie jako nowe notowania od 2022 roku.

ZapytanieSymbole ponownie wprowadzone do obrotu przez nowego emitenta po okresie nieaktywności
Dokładny kod SQL dla każdej liczby
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 year
Run this yourself

W 2022 roku 16 symboli powróciło w ten sposób, po średnim okresie nieaktywności wynoszącym 3247 dni kalendarzowych. Najdłuższy okres ciszy w tym wierszu trwał 5734 dni. Plik cenowy oparty wyłącznie na symbolu łączy historię jednej spółki z drugą, a połączenie wygląda na poprawne. Jest to przypadek, który wyłapuje „security master”, a którego nie wykryje zwykła kolumna z tickerami.

Uwagi dotyczące danych i definicje

Symbol uznaje się za nadal notowany, gdy jego ostatnia dzienna sesja przypada w ciągu 45 dni kalendarzowych od dnia wygenerowania tej strony. Ten bufor obejmuje jedno- lub dwudniowe opóźnienie na początku notowań oraz spółki o niskiej płynności, które pomijają sesje.

Stopy zwrotu to całkowite stopy zwrotu z ceny od zamknięcia w styczniu do ostatniego dostępnego zamknięcia dla danego symbolu, bez dywidend i bez annualizacji. Wcześniejsze kohorty miały więcej czasu na procent składany, więc poziomy nie są porównywalne między wierszami. Różnica między dwiema średnimi jest porównywalna.

Uniwersa wejściowe obejmują symbole wyceniane na 5 USD lub więcej ze średnim dziennym wolumenem 250 000 akcji lub więcej w miesiącu wejścia. Dzienne notowania zawierają fundusze, jednostki i warranty obok spółek operacyjnych, a nic tutaj ich nie rozdziela, więc likwidacja funduszu liczy się tak samo jak wycofanie spółki z giełdy. Symbole z konfliktem tożsamości w danych dostawcy są wykluczane w SQL z każdego panelu, zamiast być filtrowane później.

FAQ

Czym jest błąd przeżywalności w backteście akcji?

To błąd pozostający po zbudowaniu uniwersum inwestycyjnego z symboli, które istnieją do dziś. Spółki, które zostały wycofane z obrotu lub przejęte, nigdy nie trafiają do testu, więc strategia jest testowana tylko na nazwach, które dotrwały do teraźniejszości.

Skąd mam wiedzieć, czy mój zbiór danych ma błąd przeżywalności?

Weź uniwersum, które według pliku obejmowało rynek kilka lat temu, i sprawdź ostatnią sesję każdego symbolu. Jeśli każda nazwa z listy z 2016 roku nadal publikuje ceny, lista została zbudowana z obecnych notowań, a martwe nazwy nigdy się w niej nie znalazły.

Czy błąd przeżywalności zawsze sprawia, że backtest wygląda lepiej?

Nie. Kierunek zależy od tego, w jaki sposób nazwy znikały z notowań. Przejęcie może zostać sfinalizowane z premią, a pominięcie takich nazw zaniża wyniki kohorty, podczas gdy długi spadek w stronę wycofania z giełdy powoduje efekt odwrotny. Powyższe panele mierzą wynik netto obu tych zjawisk na rzeczywistych kohortach.

Czym jest uniwersum typu „point in time”?

To lista przynależności zrekonstruowana na każdą datę w teście, przy użyciu tylko rekordów, które istniały w tamtym dniu. Zawiera nazwy, które były wówczas przedmiotem obrotu i później zniknęły, oraz wyklucza nazwy, które jeszcze nie były notowane.

Czy błąd przeżywalności to to samo co błąd patrzenia w przyszłość?

Nie. Błąd patrzenia w przyszłość dotyczy czasu: test wykorzystuje informacje, zanim ktokolwiek je posiadał. Błąd przeżywalności dotyczy przynależności: test operuje na liście zbudowanej z perspektywy czasu. Backtest może być poprawny pod jednym względem, a błędny pod drugim.


Każdy panel tutaj zawiera kod SQL, który go wygenerował. Zmień miesiąc wejścia lub poluzuj filtr płynności, a następnie uruchom go w terminalu Strasmore, aby zobaczyć, jak duża część luki przetrwa Twoje własne zasady uniwersum.