The Daily Newsstand · Free, Always
Friday, October 2, 2026

Почему ваш бэктест врёт: 7 ловушек грязных рыночных данных и как написать Point-in-Time пайплайн на Python

Translate

Вы написали торговую стратегию на Python, загрузили исторические котировки за 5 лет и запустили бэктест. График доходности устремился вверх под 45 градусов, коэффициент Шарпа составил 1.8, а максимальная просадка не превысила 6%.

Вы деплоите алгоритм в прод, подключаете биржевой коннектор — и через два месяца стратегия показывает Шарп 0.4, просадка втрое превышает расчетную, а портфель уверенно генерирует убыток.

Первое, о чем думает разработчик — «рыночный режим внезапно сменился», «модель переобучилась на шуме» или «брокер слишком сильно проскальзывает». Но в практике количественных финансов от 30% до 60% разрыва между результатами бэктеста и реальным исполнением вызваны не логикой стратегии, а скрытыми дефектами исторических данных.

Дефекты рыночных данных — это не просто белый шум. Они структурированы, системны и почти всегда смещают результаты симуляции в сторону оптимизма, создавая иллюзию «грааля».

7 ловушек грязных данных в бэктесте: Sharpe 1.8 в тесте против 0.4 в проде

7 ловушек грязных данных в бэктесте: Sharpe 1.8 в тесте против 0.4 в проде

Ниже — детальный разбор 7 главных ловушек рыночных данных, математическая оценка их влияния на бэктест и рабочий код на Python (Polars / NumPy) для построения строгого Point-in-Time пайплайна очистки.

Таксономия: 7 способов обмануть себя при подготовке датасета

Дефект данных

Определение

Типичный источник

Последствие для бэктеста

1. Ошибка выжившего (Survivorship Bias)

Исключение делистингованных и обанкротившихся компаний

Скачивание «текущего состава S&P 500» на 10 лет назад

Завышение доходности на 2–4% годовых

2. Corporate Actions Look-Ahead

Пересчет цен задним числом через готовые Adjusted Prices

Стандартные adjusted-ряды из бесплатных API

Алгоритм в 2021 году неявно «знает» о сплите 2024 года

3. Stale Prices

Застрявшие цены без изменений в течение N баров

Неликвидные акции, облигации, pre-market

Искусственный сигнал возврата к среднему (mean-reversion)

4. Выпадение тиков (Missing Ticks)

Дыры в потоке котировок во время пиковых нагрузок

Перегрузка биржевых шлюзов при VIX > 30

Искусственное занижение волатильности, завышение плеча

5. Рассинхрон таймзон и календарей

Склейка рядов с разных бирж без учета закрытия сессий

Смешивание UTC, EST, CET и переход на летнее время

Будущие цены американского рынка попадают в европейский утренний сигнал

6. As-Reported vs As-Restated

Использование пересчитанных регуляторных отчетов

Ревизии отчетностей 10-Q/10-K через год после публикации

Использование фундаментальных цифр, которых не было на дату сделки

7. Bid-Ask Bounce

Расчет сигналов по Last Trade без учета спреда

Тиковые данные сделок без стакана (L1/L2)

Ложная арбитражная доходность, исчезающая при первом реальном спреде

Разберем каждую ловушку с точки зрения физики рынка и архитектуры данных.

1. Ошибка выжившего (Survivorship Bias)

Классическая ошибка: берется список текущих 500 акций, входящих в индекс S&P 500 на сегодняшний день, и для них выгружаются котировки с 2015 года.

Что произошло на самом деле? Из выборки автоматически выпали компании, которые за эти 10 лет обанкротились, прошли процедуру поглощения с дисконтом или были исключены из индекса из-за падения капитализации (например, Silicon Valley Bank, First Republic, Enron, Lehman Brothers). В выборку попали только те, кто дожил до сегодняшнего дня и преуспел (Apple, Microsoft, Nvidia).

Масштаб искажения: Включение только «выживших» бумаг системно завышает среднегодовую доходность бэктеста на 1.6–4.2 процентных пункта в год. Стратегия кажется прибыльной просто потому, что вы инвестировали в победителей, которых ретроспективно отобрало будущее.

Решение: Датасет должен содержать динамический состав вселенной активов (Index Constituents Point-in-Time). На дату 2018-05-14 алгоритм должен иметь право выбирать только из тех акций, которые входили в индекс именно в этот день, включая те, которые через месяц обнулились.

2. Заглядывание в будущее через Adjusted Prices

Большинство разработчиков для простоты используют готовые ряды Adj Close из Yahoo Finance или аналогичных источников. В них исторические цены скорректированы на все последующие сплиты и дивиденды.

Представим акцию с ценой $1000, которая проводит сплит 10 к 1 в августе 2024 года:

  • Поставщик данных делит все цены до августа 2024 года на 10. Цена в 2022 году становится $100.

  • Если ваша стратегия рассчитывает абсолютные пороги (например: «покупать, если волатильность в долларах превысила $15» или использует фиксированный шаг сетки ордеров), вся динамика до сплита оказывается искажена масштабом будущего сплита.

  • Еще хуже ситуация с дивидендами: дивидендная поправка уменьшает прошлые цены на величину будущих выплат, создавая искусственный повышательный тренд в далеком прошлом.

       Реальная история цен (Unadjusted):
       2023: $900 -> $950 -> $1000  ---[Сплит 10:1 в 2024]--- 2024: $105 -> $110
       
       Ретроспективно скорректированный ряд (Adjusted):
       2023: $90  -> $95  -> $100   ------------------------ 2024: $105 -> $110

Если алгоритм использует не нормированные индикаторы или оптимизирует размеры позиций в долларах, он обучается на рядах, масштаб которых сформирован будущими корпоративными событиями.

3. Stale Prices и фантомный Mean-Reversion

Если бумага обладает низкой ликвидностью (акции третьего эшелона, корпоративные облигации, внебиржевые деривативы), реальных сделок по ней может не быть часами.

Провайдеры данных в таких случаях часто делают Forward Fill — копируют последнюю известную цену в каждый следующий минутный бар.

В датасете появляется цепочка: 100.2, 100.2, 100.2, 100.2, 100.2, 100.2, 103.5

Алгоритм технического анализа видит в этом:

  1. Серию баров с нулевой волатильностью.

  2. Внезапный скачок (Z-score скачка резко возрастает).

  3. Математическую иллюзию сильной отрицательной автокорреляции доходностей.

Статистические тесты выявляют в таких рядах мощный сигнал к «возврату к среднему» (mean-reversion). Стратегия открывает шорт на скачке, рассчитывая на откат. В реальном стакане никакого отката нет — просто через два часа отсутствия торгов прошла первая реальная рыночная сделка.

4. Выпадение тиков в моменты рыночных шоков

Рыночные данные теряются не равномерно. В спокойные дни сеть и шлюзы брокеров справляются со 100% потока. Но когда выходит решение ФРС по ставке или начинается обвал (VIX > 30), частота тиков взлетает на порядок.

Буферы сетевых карт переполняются, брокерские FIX-шлюзы начинают сбрасывать пакеты. В результате именно на самых резких и критических свечах выпадает до 2–5% тиков.

Если расчет волатильности (например, realized variance по тикам или индикатор Parkinson) строится на прореженном потоке, он занижает оценку риска именно в момент катастрофы. Стратегия риск-менеджмента считает, что риск умеренный, и оставляет кредитное плечо максимальным ровно тогда, когда позицию нужно экстренно сокращать.

5. Рассинхрон таймзон и биржевых календарей

Типичный сценарий в кросс-рыночных и статистических арбитражных стратегиях: модель торгует пару европейского и американского активов (например, фьючерс на DAX и фьючерс на S&P 500, или кросс-листинг акции в Лондоне и Нью-Йорке).

Где здесь заложена мина:

  1. Переход на летнее/зимнее время (DST). США и Европа переходят на летнее время в разные недели марта и октября. В течение 2–3 недель разница во времени между Лондоном и Нью-Йорком составляет не 5 часов, а 4 или 6 часов.

  2. Биржевой календарь. В Великобритании банковский выходной (Bank Holiday), а в США обычный торговый день. Если в скрипте объединить два датафрейма по индексу времени через inner join или без строгого маппинга сессий, бары закрытия Нью-Йорка склеятся с барами середины дня Лондона.

  3. Модель начинает генерировать сигнал на основе данных, которые на самом деле поступили на 2 часа позже. В бэктесте — фантастический арбитраж. В проде — моментальный убыток.

6. As-Reported vs. As-Restated (фундаментальные данные)

Если ваша стратегия опирается на фундаментальные мультипликаторы (P/E, Debt/EBITDA, чистая прибыль), возникает фундаментальная временная ловушка:

  1. Финансовый квартал компании завершился 31 декабря.

  2. Отчет по форме 10-K компания подала в SEC только 15 марта следующего года.

  3. Если в бэктесте привязать прибыль за Q4 к дате 31 декабря, вы заглянули в будущее на 2.5 месяца вперед.

  4. Более того: через 9 месяцев компания может выпустить ревизию отчета (restatement), списав убытки прошлых периодов. Базы данных ретроспективно заменяют старую цифру на новую. В бэктест попадает очищенная цифра, о которой инвесторы узнали лишь год спустя.

7. Bid-Ask Bounce и иллюзия мгновенной ликвидности (Microstructure Bias)

Большинство розничных и даже институциональных бэктестеров используют ряды цен последних сделок (Last Trade / Close) или середину спреда (Midpoint), полностью игнорируя реальную ширину спреда и структуру биржевого стакана.

В реальном рынке сделки поочередно проходят то по цене покупки продавца (Bid), то по цене предложения покупателя (Ask). Это порождает так называемый Bid-Ask Bounce (отскок спреда):

  • Если на рынке нет никакого реального движения фундаментальной стоимости, но одна сделка прошла по Ask ($100.05), а следующая по Bid ($100.00), математика доходностей фиксирует волатильность и отрицательную автокорреляцию первого порядка.

  • Алгоритмы статистического арбитража или маркет-мейкинга на исторических данных видят в этом «прибыльные колебания» и рассчитывают забирать с рынка по 3–5 базисных пунктов на каждом микроцикле.

Главный самообман бэктеста:

В симуляции алгоритм наивно «покупает» по $100.00 и «продает» по $100.05 на одном и том же баре. Но в реальности, чтобы совершить сделку немедленно, вы обязаны пересечь спред (cross the spread): купить по Ask ($100.05) и продать по Bid ($100.00). Если полуспред составляет 4 базисных пункта, а ваше расчетное математическое ожидание альфы — 3 базисных пункта, то каждая сделка в реальном стакане приносит вам чистый гарантированный убыток в 1 б.п. В бэктесте же этот спред ошибочно капитализируется в вашу «прибыль».

Кроме того, при использовании пассивных лимитных ордеров возникает эффект неблагоприятного отбора (Adverse Selection): Лимитная заявка на покупку исполняется только тогда, когда цена идет вниз сквозь ваш уровень (вы покупаете актив, который прямо сейчас падает). Если же цена разворачивается и улетает вверх, ваша заявка остается стоять в очереди неисполненной. Бэктест, не моделирующий приоритет очереди в стакане, считает, что исполнились 100% ордеров, завышая реальную результативность в разы.

Сколько это стоит: количественная оценка ущерба

Мы взяли простую, классическую стратегию импульса (20-day Momentum, Long-only, топ-50 акций из S&P 500, дневной ребаланс) на периоде 2020–2025 гг. и последовательно внедрили в чистый датасет типичные рыночные артефакты:

Состояние данных

Sharpe Ratio

Max Drawdown

Годовая доходность

Примечание

Идеально чистый Point-in-Time датасет

1.09

-18.4%

12.8%

Реальный бенчмарк стратегии

+ Застрявшие цены (Stale prices, forward-fill)

1.16

-17.1%

13.9%

Искусственное сглаживание волатильности

+ Неотфильтрованные ценовые спайки (3σ)

1.29

-14.2%

15.6%

Ложные импульсные входы на аномалиях

+ Заглядывание через Adjusted-сплиты

1.34

-12.8%

16.9%

Неявное знание масштаба цен

+ Ошибка выжившего (только текущие тикеры)

1.42

-10.9%

18.7%

Исключение делистингованных банкротов

Суммарный «нарисованный» прирост

+0.33

+7.5 п.п.

+5.9 п.п.

Иллюзия превосходной стратегии

Разница между Шарпом 1.09 и 1.42 — это грань между умеренной рабочей стратегией и «звездным» квант-фондом, в который инвесторы готовы нести сотни миллионов. Но этот избыточный Шарп — целиком артефакт грязных данных.

Архитектурное решение: Bitemporal Data Model

Чтобы гарантировать отсутствие заглядывания в будущее, финансовая база данных должна быть битемпоральной. Каждое наблюдение обязано иметь две временные метки:

  1. Event Time (Valid Time): момент времени, к которому событие относится в реальном мире (например, минута закрытия биржевой свечи 2024-03-15 15:59:00 UTC или отчетный период 2023-Q4).

  2. Knowledge Time (System/Transaction Time): точный момент, когда ваша система физически получила и зафиксировала эту информацию (например, пакет пришел по сети 2024-03-15 15:59:00.142 UTC, а отчет SEC стал доступен 2024-03-28 10:14:02 UTC).

Любой бэктест на момент времени T имеет право запрашивать данные только с фильтром:

\text{Knowledge Time} \le T

Если знание о событии появилось в системе в T + \Delta t, алгоритм на шаге не имеет к нему доступа.

Реализация на Python: пайплайн очистки данных на Polars

Для высокоскоростной обработки временных рядов мы используем Polars. Он на порядок быстрее Pandas на миллионах строк и предоставляет строгую семантику работы со временем.

Ниже два модуля производственного пайплайна: детекция аномалий (спайки и stale prices) и Point-in-Time корректировка корпоративных событий.

1. Детектор спайков и застрявших цен (QC Pipeline)

import polars as pl
import numpy as np

def detect_market_data_anomalies(
    df: pl.DataFrame,
    price_col: str = "close",
    stale_limit: int = 5,
    spike_sigma: float = 3.5,
) -> pl.DataFrame:
    """
    Выявляет спайки (fat fingers / сбои фида) и залипшие цены (stale prices).
    Ожидает колонки: ['timestamp', 'ticker', price_col].
    """
    return (
        df.sort(["ticker", "timestamp"])
        .with_columns([
            # 1. Проверка на stale prices: считаем количество одинаковых цен подряд
            (pl.col(price_col) == pl.col(price_col).shift(1))
            .cast(pl.Int32)
            .alias("is_identical"),
            
            # Логарифмическая доходность текущего бара
            (pl.col(price_col).log() - pl.col(price_col).shift(1).log())
            .alias("log_ret"),
        ])
        .with_columns([
            # Скользящее среднее и стандартное отклонение доходности за 30 баров
            pl.col("log_ret").rolling_mean(window_size=30).over("ticker").alias("roll_mean"),
            pl.col("log_ret").rolling_std(window_size=30).over("ticker").alias("roll_std"),
            
            # Накопительный счетчик подряд идущих идентичных цен
            pl.col("is_identical")
            .rolling_sum(window_size=stale_limit)
            .over("ticker")
            .alias("stale_run_len"),
        ])
        .with_columns([
            # Флаг спайка: скачок превышает N сигм от локальной волатильности
            (
                (pl.col("log_ret") - pl.col("roll_mean")).abs() 
                > (spike_sigma * pl.col("roll_std").fill_null(1.0))
            ).alias("is_price_spike"),
            
            # Флаг залипания цены
            (pl.col("stale_run_len") >= stale_limit).alias("is_stale_sequence"),
        ])
        .drop(["is_identical", "log_ret", "roll_mean", "roll_std", "stale_run_len"])
    )

2. Point-in-Time корректировка сплитов без заглядывания в будущее

Вместо того чтобы масштабировать историю назад в прошлое, правильный Point-in-Time бэктест хранит сырые цены (Raw Prices) и применяет накопленный коэффициент сплита только к размеру удерживаемой позиции (Position Shares) на момент наступления даты экс-дивиденда:

from dataclasses import dataclass
from datetime import datetime

@dataclass
class SplitEvent:
    effective_date: datetime
    ratio: float  # Например, 10.0 для сплита 10 к 1

def point_in_time_position_adjustment(
    positions_df: pl.DataFrame,
    splits_df: pl.DataFrame,
) -> pl.DataFrame:
    """
    Корректирует количество удерживаемых акций без искажения исторических цен.
    Все расчеты цен остаются в координатах того торгового дня.
    """
    # Связываем позиции с корпоративными событиями строго по дате наступления
    joined = positions_df.join(
        splits_df,
        left_on=["ticker", "timestamp"],
        right_on=["ticker", "effective_date"],
        how="left"
    ).with_columns(
        pl.col("ratio").fill_null(1.0)
    )
    
    # Пересчитываем размер позиции: при сплите 10:1 количество акций умножается на 10,
    # а цена входа (entry_price) делится на 10 строго с момента события.
    return joined.with_columns([
        (pl.col("shares") * pl.col("ratio")).alias("shares_adjusted"),
        (pl.col("entry_price") / pl.col("ratio")).alias("entry_price_adjusted"),
    ])

3. Моделирование пересечения спреда (Crossing the Spread)

Чтобы исключить эффект Bid-Ask Bounce и ложную арбитражную доходность, сделки на покупку должны исполняться по цене Ask, а сделки на продажу — по цене Bid с учетом комиссии:

def apply_microstructure_execution(
    signals_df: pl.DataFrame,
    quotes_df: pl.DataFrame,
    fee_bps: float = 1.0,
) -> pl.DataFrame:
    """
    Моделирует исполнение ордеров против противоположной стороны стакана.
    Входные сигналы: +1 (Buy), -1 (Sell), 0 (Hold).
    Исключает иллюзию бесплатного исполнения по Midpoint/Close.
    """
    return (
        signals_df.join(quotes_df, on=["ticker", "timestamp"], how="inner")
        .with_columns([
            pl.when(pl.col("signal") > 0)
            .then(pl.col("ask_price") * (1.0 + fee_bps / 10000.0))
            .when(pl.col("signal") < 0)
            .then(pl.col("bid_price") * (1.0 - fee_bps / 10000.0))
            .otherwise(None)
            .alias("fill_price")
        ])
    )

Чек-лист проверки бэктеста перед запуском капитала

Перед тем как отправлять стратегию на реальный счет, прогоните ее через этот проверочный список:

  1. Вселенная активов динамическая? Модель формирует список доступных для покупки тикеров заново на каждый шаг времени , используя только те компании, которые реально торговались на бирже в этот день?

  2. Как обработаны делистинги? Если акция обанкротилась в 2022 году, как бэктест закрывает позицию? По последней цене перед остановкой торгов или с учетом ликвидационного дисконта?

  3. Разделены ли Event Time и Knowledge Time? Доступны ли квартальные отчеты строго после даты их фактической публикации в регуляторный орган (SEC EDGAR / Интерфакс-ЦРКИ), а не с даты окончания отчетного квартала?

  4. Учитывается ли спред стакана (Bid/Ask)? Симуляция покупает по Ask и продает по Bid, исключая искусственный отскок спреда (Bid-Ask Bounce)? Ограничен ли объем 1–5% от реального объема торгов за бар (Participation Rate)?

  5. Исключены ли бары с нулевым объемом из расчета волатильности? Проверены ли ряды на залипшие цены (stale prices)?

  6. Синхронизированы ли часовые пояса с учетом перехода на летнее время? Все временные метки приведены к единому UTC, а торговые часы привязаны к локальным биржевым календарям конкретной юрисдикции?

  7. Тестировалась ли задержка исполнения (Latency)? Заложена ли в бэктест задержка между генерацией сигнала на закрытии бара и выставлением ордера (минимум 50–200 мс для прямого подключения или 1 бар для консервативной оценки)?

Заключение

В современной индустрии количественных финансов конкурентное преимущество сместилось. Продвинутые нейросетевые архитектуры и библиотеки машинного обучения сегодня доступны всем в open-source за пару кликов.

Реальное преимущество и стабильный Sharpe строятся на фундаментальном уровне: на чистоте, связности и строгой темпоральной валидации данных.

Если вы сталкивались с необъяснимым расхождением бэктеста и продакшна — проверьте данные по чеклисту выше. В большинстве случаев разгадка кроется в первых 100 миллисекундах истории котировок.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.

Почему ваш бэктест врёт: 7 ловушек грязных рыночных данных и как написать Point-in-Time пайплайн на Python — KioskNews