Ошибка выжившего в биржевых данных: как избежать искажений
Ошибка выжившего скрывает делистингованные и поглощенные компании из большинства наборов данных. Узнайте, как этот фактор искажает результаты бэктестов и как оценить рынок целиком.
Ошибка выжившего в биржевых данных — это искажение, возникающее, когда файл содержит только те тикеры, которые торгуются по сей день. Все компании, которые были делистингованы, поглощены, переименованы или ликвидированы, молчаливо отсутствуют. Бэктест, запущенный на такой выборке, оценивает отфильтрованную историю, а не реальное состояние рынка на тот момент. В файле всё выглядит корректно: каждая строка содержит реальную цену, а проблема заключается в строках, которые никто не записал.
Что такое ошибка выжившего в биржевых данных?
Представьте, как формируется набор данных «все акции США». Запрос к провайдеру получает список символов, а затем подтягивает историю цен для каждого имени из списка. Этот список актуален по определению. Он отвечает на вопрос «что торгуется сейчас», и каждый символ, который перестал торговаться до момента запроса, отсутствует в ответе вместе со всей своей историей.
Термин происходит из работы Абрахама Вальда 1943 года о возвращающихся бомбардировщиках: броня была нужна там, где у вернувшихся самолетов не было пробоин, так как самолеты, получившие попадания в другие места, просто не вернулись для осмотра. База данных акций, построенная на сегодняшних листингах, имеет ту же форму. Вы видите только ту выборку, которая «вернулась домой», а членство в ней определяется именно тем исходом, который пытается измерить бэктест.
Сколько тикеров исчезает каждый год?
Начнем с масштаба проблемы. В таблице ниже все символы из ежедневной ленты торгов сгруппированы по календарному году их последнего бара; включены только те символы, которые перестали торговаться до января 2026 года.
Точный SQL-код для каждого числа
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В 2025 году 1283 символов напечатали последний дневной бар и больше не вернулись. Это не «пустышки» недельной давности: в этой группе среднее количество дневных баров с 2015 года составило 999. За 10 года, представленных в таблице, количество меняется, и ни в одном году показатель не приближается к нулю. Символы уходят по обычным причинам: закрытие сделки по поглощению, ликвидация фонда, слияние, при котором один листинг поглощает другой, или несоответствие стандартам листинга.
Наблюдаем появление ошибки в десятистрочном скрипте
Арифметику проще увидеть, чем описать. Скрипт ниже создает два небольших CSV-файла: один — только с «выжившими», второй — с тем же набором, но с добавлением имен, которые перестали торговаться. Он вычисляет среднее значение для каждого файла и выводит разницу. Используется только стандартная библиотека Python, устанавливать ничего не нужно.
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
Семь показателей доходности в этом скрипте вымышлены, это не рыночные данные. Они нужны, чтобы сделать механизм наглядным. Среднее значение «только выживших» составляет +32,5%, полная вселенная дает +0,9%, а разница в 31,6 пункта — это и есть ошибка. В файле «только выживших» нет лжи. Каждое из этих четырех имен действительно показало указанную доходность. Файл просто не упоминает те три, которые потянули бы среднее значение вниз.
Те же измерения на реальных когортах
Теперь проведем тот же расчет на реальных данных. В таблице ниже строится начальная вселенная в январе каждого года с 2016 по 2022, включая символы с ценой выше 5 долларов и средним дневным объемом более 250 000 акций, а затем отслеживается каждый из них до последнего закрытия. Имена, которые перестали торговаться, учитываются по их последней цене, а не исключаются. Столбец «выжившие» усредняет только те символы, которые торгуются до сих пор. Столбец «полная вселенная» усредняет всех, кто был в списке на старте.
Точный SQL-код для каждого числа
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Для когорты 2016 года 968 из 2728 имен исчезли из ленты к моменту создания этой страницы. Усреднение только выживших дает 212%. Усреднение всей когорты дает 149.3%. Столбец «разрыв» показывает дистанцию между этими двумя значениями: 62.7 пунктов для этой когорты и 9.1 пунктов для когорты 2022 года, у которой было гораздо меньше времени на потерю участников. Это общая доходность за периоды разной длины, поэтому читайте столбец «разрыв» сверху вниз, а не сравнивайте уровни между строками. Тот же вопрос о составе вселенной лежит в основе того, как измеряется месячная доходность, и в основе каждого равновесного среднего.
Куда делись исчезнувшие имена
Делистинг не всегда означает полное обесценивание. В таблице ниже взята одна начальная вселенная (январь 2019 года) и отсортирована по дальнейшим событиям.
Точный SQL-код для каждого числа
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Группа Still trading содержит 1954 имен со средней доходностью 139.8% от цены входа в январе 2019 года, при этом 72.1% из них находятся выше цены входа. Посмотрите на строки 9: выходы выглядят неоднородно. Компания, купленная с премией, может покинуть ленту на сильном последнем принте, в то время как компания, угасающая до уведомления о делистинге, уходит на слабом. Оба случая отсутствуют в файле «только выживших». Верните их, и среднее значение изменится.
Ошибка выжившего против ошибки заглядывания в будущее
Эти два понятия часто идут вместе, но они нарушают бэктест в разных местах. Ошибка заглядывания в будущее — это проблема тайминга: тест использует факт в момент, когда никто еще не обладал этой информацией (например, пересмотренные показатели прибыли или корректировки, примененные месяцы спустя). Ошибка выжившего — это проблема состава: список, на котором торгует тест, — это список, существующий сегодня, а не тот, что существовал тогда. Бэктест может быть безупречен по таймингу, но при этом торговать вселенной, собранной с использованием ретроспективного взгляда. Корректировки цен несут схожую ловушку, так как история цен с поправкой на сплит переписывает уровень каждого бара до даты сплита.
Как исправить ошибку выжившего в бэктесте
- Перестраивайте вселенную на каждую дату. Состав «на момент времени» означает, что список реконструируется для дня, на котором находится тест, из записей, существовавших в тот день. Тест включает имена, которые были активны тогда, включая те, что позже исчезли.
- Ведите мастер-реестр ценных бумаг. Мастер-реестр сопоставляет идентификаторы во времени: какой символ указывал на какую компанию в какие даты и что заменило ее после слияния или переименования. Файлы цен привязаны к символам. Компании — нет.
- Закрывайте делистингованные имена по их последней реальной цене. Когда имя уходит из ленты, позиция закрывается по последнему принту, а не исчезает из среднего значения. Удаление — это то, что создает разрыв в таблицах выше. Воспроизводимый бэктест закрепляет это правило в коде рядом с определением вселенной.
- Читайте документацию провайдера о «мертвых» символах. Поставщики, которые относятся к этому серьезно, описывают это. Massive предоставляет делистингованные символы через эндпоинт тикеров с флагом активности и хранит изменения символов, делистинги, слияния и поглощения как записи в отдельном эндпоинте событий тикеров.
«Мы не меняем данные и не объединяем изменения тикеров в единую совокупную серию».
База знаний Massive, «Как Massive обрабатывает изменения тикеров и поглощения?», прочитано в августе 2026 года.
Если в документации провайдера ничего не сказано о делистингованных символах, считайте данные «только для выживших», пока не проверите обратное. Покрытие сильно варьируется между источниками: см. наши заметки о бесплатных API биржевых данных.
Ловушка повторного использования тикеров
Символы возвращаются в обращение после окончания листинга. В таблице ниже подсчитаны символы, которые напечатали не менее 200 дневных баров, «молчали» не менее 250 календарных дней, а затем появились снова как совершенно новый листинг с 2022 года.
Точный SQL-код для каждого числа
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В 2022 году 16 символов вернулись таким образом после периода тишины в среднем 3247 календарных дней. Самый длинный период тишины в этой строке составил 5734 дней. Файл цен, привязанный только к символу, «сшивает» историю одной компании с другой, и соединение выглядит чистым на всем протяжении. Это случай, который ловит мастер-реестр, но не ловит простой столбец тикеров.
Примечания к данным и определения
Символ считается торгуемым, если его последний дневной бар приходится на период в пределах 45 календарных дней от даты генерации этой страницы. Этот буфер покрывает задержку в один-два дня в начале ленты и неликвидные бумаги, которые пропускают сессии.
Доходность — это общая ценовая доходность от закрытия входа в январе до последнего доступного закрытия для этого символа, без учета дивидендов и без годовой нормализации. Более ранние когорты имели больше времени для накопления результата, поэтому уровни не сопоставимы между строками. Разрыв между двумя средними значениями — сопоставим.
Входные вселенные включают символы с ценой 5 долларов и выше со средним дневным объемом 250 000 акций и более в течение месяца входа. Ежедневная лента содержит фонды, юниты и варранты наряду с операционными компаниями, и здесь нет их разделения, поэтому закрытие фонда учитывается наравне с корпоративным делистингом. Символы с известным конфликтом идентификации в лентах поставщиков исключаются через SQL из каждой таблицы, а не фильтруются после.
Часто задаваемые вопросы
Что такое ошибка выжившего в биржевом бэктесте?
Это ошибка, возникающая, когда торгуемая вселенная строится из символов, существующих сегодня. Компании, которые были делистингованы или поглощены, никогда не попадают в тест, поэтому стратегии предлагаются только те имена, которые дожили до настоящего времени.
Как узнать, есть ли в моем наборе данных ошибка выжившего?
Возьмите вселенную, которую ваш файл якобы охватывает на дату несколько лет назад, и проверьте последний бар каждого символа. Если каждое имя из списка 2016 года до сих пор печатает цены, значит, список был построен из текущих листингов, а «мертвых» имен в нем никогда не было.
Всегда ли ошибка выжившего делает бэктест лучше?
Нет. Направление зависит от того, как имена покидали ленту. Поглощение может закрыться с премией, и исключение таких имен занижает показатели когорты, в то время как долгое угасание до делистинга дает обратный эффект. Таблицы выше измеряют чистый результат обоих факторов на реальных когортах.
Что такое вселенная «на момент времени» (point in time)?
Это список участников, реконструированный на каждую дату теста с использованием только тех записей, которые существовали в тот день. Он включает имена, которые были торгуемы тогда и позже исчезли, и исключает имена, которые еще не были листингованы.
Ошибка выжившего — это то же самое, что ошибка заглядывания в будущее?
Нет. Ошибка заглядывания в будущее касается тайминга: тест использует информацию до того, как она стала доступна. Ошибка выжившего касается состава: тест торгует списком, собранным с использованием ретроспективного взгляда. Бэктест может быть чист по одному параметру и испорчен по другому.
Каждая таблица здесь содержит SQL-запрос, который ее создал. Измените месяц входа или ослабьте фильтр ликвидности, затем запустите его в терминале Strasmore, чтобы увидеть, какая часть разрыва сохранится при ваших собственных правилах формирования вселенной.