Поиск по материалам

Walk-forward на IMOEX: как не обмануть себя бэктестом

·

Backtest показывает Sharpe 2.4, а на реальных деньгах стратегия сваливается в drawdown с первой недели. Разбираем, почему in-sample оптимизация почти всегда лжёт и как walk-forward возвращает реалистичные ожидания.

TL;DR

  • Любая оптимизация параметров на одной выборке завышает доходность
    в 2–5 раз — это optimization bias, и он не лечится «здравым
    смыслом».
  • Walk-forward analysis (WFA) — это процедура, которая разбивает
    историю на последовательные in-sample / out-of-sample окна и
    считает результат только по out-of-sample.
  • На IMOEX с 2018 по 2026 типичная momentum-стратегия теряет
    60–80% «бумажного» Sharpe при честном WFA. Если после этого
    что-то остаётся — это можно нести в прод.

Почему обычный бэктест врёт

Допустим, вы тестируете стратегию вида «купить, если цена выше
SMA(N), продать, если ниже» на IMOEX. Перебираете N от 5 до 200
и выбираете тот, который даёт лучший Sharpe. Поздравляю — вы
только что подогнали параметр под конкретную реализацию шума.

Это легко проверить: сгенерируйте случайные ряды с тем же
volatility profile, что у IMOEX, и прогоните ту же оптимизацию.
На большинстве синтетических серий «лучший» N выдаст Sharpe 0.8–1.5
— на чистом шуме. На реальных данных вы увидите ровно те же
«красивые» цифры, и не сможете отличить настоящий сигнал от
артефакта оптимизации.

Формальная причина

Если вы перебираете K вариантов параметров, ожидаемое значение
максимального Sharpe из K случайных стратегий растёт как
sqrt(2 ln K). Уже при K=100 это даёт +3 sigma «бесплатного»
Sharpe. Любое мало-мальски сложное пространство параметров
(две скользящие, ATR-фильтр, трейлинг-стоп) — это тысячи
комбинаций.

Anchored walk-forward: 5 итераций train→test
Anchored walk-forward: 5 итераций train→test

Walk-forward по шагам

Идея простая: оптимизируем на куске истории, торгуем на
следующем (out-of-sample), сдвигаемся вперёд, повторяем.

├─── train 2018–2019 ───┤├─ test Q1'20 ─┤
    ├─── train 2018Q2–2020Q1 ───┤├─ test Q2'20 ─┤
        ├─── train 2018Q4–2020Q3 ───┤├─ test Q3'20 ─┤
            ...

Параметры оптимизируются только на train, метрики берутся
только с test. Конкатенация всех test-окон даёт честную
equity-curve.

Ключевые решения

  1. Размер train-окна. Слишком короткое — переподгонка под
    локальный режим (2022 vs 2024 — разные миры). Слишком длинное
    — параметры усредняются по разным режимам и не работают
    нигде. Для IMOEX практичный диапазон — 18–36 месяцев.
  2. Размер test-окна. Чем меньше, тем чаще rebalance
    параметров — и тем больше transaction cost. Для дневок
    удобный шаг — 1 месяц.
  3. Anchored vs rolling. Anchored расширяет train-окно
    (всё больше истории), rolling сдвигает (фиксированная
    длина). Anchored устойчивее к шуму, rolling быстрее
    адаптируется к смене режима.

Реализация на Python

Минимальный каркас без зависимостей сверх pandas:

import pandas as pd
import numpy as np

def walk_forward(prices: pd.Series,
                 train_months: int = 24,
                 test_months: int = 1,
                 optimize_fn=None):
    results = []
    start = prices.index[0]
    end   = prices.index[-1]

    cursor = start + pd.DateOffset(months=train_months)
    while cursor + pd.DateOffset(months=test_months) <= end:
        train = prices[cursor - pd.DateOffset(months=train_months) : cursor]
        test  = prices[cursor : cursor + pd.DateOffset(months=test_months)]

        best_params = optimize_fn(train)
        pnl = simulate(test, best_params)
        results.append({
            "from":    test.index[0],
            "to":      test.index[-1],
            "params":  best_params,
            "pnl":     pnl.sum(),
            "sharpe":  pnl.mean() / pnl.std() * np.sqrt(252),
        })
        cursor += pd.DateOffset(months=test_months)

    return pd.DataFrame(results)

optimize_fn принимает train-серию и возвращает лучшие
параметры. simulate — это ваш бэктест-движок, который по
ценам и параметрам строит equity. Принципиально, что
optimize_fn не видит test-выборку.

Что обычно отваливается

Прогонял три классики на IMOEX (2018-01 → 2026-04, дневные
закрытия, без учёта дивидендов):

Стратегия IS Sharpe OOS Sharpe (WFA) Drop
SMA-cross (N1, N2) 1.82 0.34 -81%
Donchian breakout (N) 1.61 0.58 -64%
RSI mean-reversion 2.07 -0.12 -106%

RSI mean-reversion — классический пример: на IS красиво
«ловит откаты», на OOS работает хуже buy&hold. Это не
«плохая стратегия» — это подобранные под прошлое
параметры, которые на новых данных не воспроизводятся.

Чек-лист «честного» WFA

  • Train и test никогда не пересекаются по времени.
  • Все производные фичи (скользящие, волатильность, бэты)
    считаются с lookback внутри train-окна, без peeking.
  • Optimization metric — это то, что вы реально хотите
    максимизировать в проде (например, Sharpe minus turnover
    cost), а не просто PnL.
  • Из «лучших» параметров на train вы выбираете не один
    глобальный максимум, а робастную область (top-decile,
    усреднение по top-N). Иначе ловите шум.
  • Учтены реальные комиссии и проскальзывание Мосбиржи
    (минимум 0.04% per trade на ликвидных бумагах,
    до 0.15% на втором эшелоне).
  • Период тестирования включает разные режимы:
    2020 (ковид), 2022 (приостановка торгов), 2024–2025
    (восстановление). Если стратегия работает только в
    трендовый 2023 — это не стратегия.

Что делать с результатом

Если после WFA Sharpe > 1.0 на OOS, drawdown < 25%, и метрика
устойчива к смещению окон на ±2 месяца — стратегию можно
нести в paper trading. Если красиво только при одном конкретном
размере окна — это последний звоночек переподгонки.

И ещё одно: walk-forward не панацея. Он защищает от
оптимизации параметров, но не от переборного отбора стратегий.
Если вы протестировали 200 идей и выбрали ту, что прошла WFA —
у вас всё ещё есть selection bias. Лекарство — заранее
фиксировать гипотезу и считать поправку Бонферрони на
количество тестов.

walk-forward бэктесты IMOEX стратегии квант