👀 PairScan

· 3 мин чтения · #methodology #backtest #walk-forward #technical

Walk-forward vs in-sample бэктест

Почему walk-forward бэктесты честные, а in-sample — нет. Короткий технический пост с кодом.

Есть один самый частый генератор fake-alpha в retail-бэктестах: использование full-sample данных для установки порогов и потом «бэктест» стратегии на тех же данных. Этот пост объясняет почему это неправильно, что делать вместо, и как верифицировать что твой код случайно не делает этого.

Корневая проблема

Скажем, у тебя 540 дней ценовых данных и ты хочешь бэктестить mean-reversion стратегию которая свопает когда log-ratio касается 5-го или 95-го перцентиля своего исторического диапазона.

Наивный (и неправильный) подход:

# WRONG: in-sample бэктест
p5 = np.percentile(log_ratio, 5)   # использует ВСЕ 540 дней
p95 = np.percentile(log_ratio, 95) # включая будущее относительно t=0

for t in range(540):
    position = (log_ratio[t] - p5) / (p95 - p5)
    # решения на основе БУДУЩИХ перцентилей

Баг тонкий: в момент t=0 порог p5 был посчитан на данных от t=0 до t=540, включая будущие данные, которых стратегия в момент t=0 бы не знала. По сути ты читишь.

Результат: бэктест-returns inflate'ят на 10-30%, потому что в каждой decision-точке ты используешь «то что мы теперь знаем про полный диапазон» чтобы решать где границы.

Walk-forward фикс

Walk-forward использует только данные до (но не включая) decision-точки:

# RIGHT: walk-forward бэктест
LOOKBACK = 180  # дней

for t in range(LOOKBACK, len(log_ratio)):
    window = log_ratio[t - LOOKBACK:t]  # НЕ [t - LOOKBACK:t + 1]
    p5 = np.percentile(window, 5)
    p95 = np.percentile(window, 95)
    position = (log_ratio[t] - p5) / (p95 - p5)
    # решения ТОЛЬКО на основе прошлых данных

Критическая деталь: slice заканчивается на t, не t + 1. Если включить индекс t, ты включил сегодняшнюю цену в сегодняшний порог — это mild lookahead, но всё равно bias.

Пороги пересчитываются каждый день на trailing-окне. Это честно симулирует, что ты бы видел гоняя стратегию в real-time.

Как верифицировать что бэктест честный

Простейший тест: испорти будущее и проверь что прошлые сделки не изменились.

def test_no_lookahead(backtest_fn, prices, midpoint=300):
    """Прошлые решения не должны меняться при corruption будущих данных."""

    # Прогон на чистых данных
    trades_clean = backtest_fn(prices)

    # Заменяем будущие данные мусором
    prices_corrupt = prices.copy()
    prices_corrupt[midpoint:] = 999_999.0

    trades_corrupt = backtest_fn(prices_corrupt)

    # Фильтруем сделки до midpoint
    clean_before = [t for t in trades_clean if t.time < midpoint]
    corrupt_before = [t for t in trades_corrupt if t.time < midpoint]

    assert clean_before == corrupt_before, "Lookahead bias detected"

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

У нас этот тест в CI open-source утилиты: github.com/pairscan/ratio-mean-reversion/tests/test_no_lookahead.py. Это гейт защищающий от subtle bias-багов в продакшене.

Практические импликации

Если бэктест claim'ит +60% returns за 360 дней, но не показывает walk-forward методологию — discount'и число сильно. Реальные returns будут на 10-30% ниже минимум, часто больше.

Если оцениваешь скринер или стратегию:

  1. Спроси — walk-forward ли бэктест?
  2. Спроси какое lookback-окно использовано для перцентилей?
  3. Если не могут ответить или не знают — относись к опубликованным returns как к маркетингу, не к данным.

PairScan публикует свою полную имплементацию бэктеста (open source, MIT). Каждая пара показанная на дашборде использует walk-forward перцентильные границы с 180-дневным rolling-окном, no lookahead, с explicit corruption test'ом в CI. Тот же код, что гоняется в продакшен-скринере, гоняется в test suite.

Заметка про выбор lookback

Сам выбор lookback-окна включает trade-off:

  • Короткий lookback (60-90 дней): перцентили быстро адаптируются к regime change'ам. Но меньше данных для robust порогов, шум сигнала растёт.
  • Длинный lookback (360+ дней): стабильные пороги, меньше шума. Но медленно адаптируется к genuine regime change'ам.

Мы используем 180 дней как компромисс. Достаточно длинно чтобы поймать seasonal-ish паттерны, но достаточно коротко чтобы обновляться на regime-сдвигах в квартал-два. Это параметр, который ты можешь тюнить под свой use case если форкнешь open-source утилиту.

Чего избегать

Три ещё распространённых ошибок стоит флагать:

1. Look-ahead через dependent variables. Если твоя стратегия использует volatility-adjusted entry threshold, убедись что volatility тоже считается только из прошлых данных. Легко забыть когда несколько inputs.

2. Survivorship bias. Бэктест только на парах существующих сегодня исключает пары которые делистились по пути. Реалистичная вселенная должна включать пары которые сломались за период.

3. Оптимистичные execution-assumption'ы. Walk-forward фиксит lookahead, но не адресует slippage, fees, partial fills. Предполагай реальные returns на 10-30% ниже даже честного бэктеста.

Bottom line

Walk-forward не optional для честного бэктеста. Любой retail «бэктест» не использующий его — либо маркетинг, либо баг. No-lookahead тест занимает 10 минут на написание и защищает от месяцев плохих решений.

Если используешь third-party инструмент — задай вопрос. Если строишь свой — пиши тест перед стратегией. Тест — это гейт, превращающий бэктест в evidence.