← К списку статей
July 8, 2026
5 мин. чтения

Доказываем отсутствие look-ahead в мультитаймфреймовых бэктестах: возмущаем будущее, доказываем, что прошлое его не видит

Доказываем отсутствие look-ahead в мультитаймфреймовых бэктестах: возмущаем будущее, доказываем, что прошлое его не видит
#алготрейдинг
#бэктест
#look-ahead
#мультитаймфрейм
#утечка данных
#валидация
🎯
Часть 2 из 9 · Подборка
Бэктест без самообмана

Часть серии "Бэктесты без иллюзий".

Предыдущая статья закончилась диагностикой: тестом сдвига на один бар. Сдвиньте каждое исполнение на один бар позже; если результат рушится — значит, вы торговали в прошлом. Этого теста достаточно для стратегии на одном таймфрейме, где look-ahead прячется в исполнении — решение принято на баре i, и исполнение прошло на баре i.

У мультитаймфреймовых стратегий есть второе, более тихое место для утечки, и тест сдвига до него не дотягивается. Когда решение на 1-минутном баре опирается на часовой тренд, утечка происходит уже не в исполнении — она в индикаторе, вычисленном из бара старшего таймфрейма, который еще не закончил формироваться. Эта статья про эту утечку и про то, как доказать — механически, а не разглядыванием кода, — что в вашем движке ее нет.

Утверждение, к которому мы придем в конце, сильное: наш мульти-TF движок прошел 25 из 25 проверок паритета и утечек на 86,400 барах реальных данных ETHUSDT 1m, включая пробу со сдвинутым будущим, которая возмущает каждый будущий бар и подтверждает, что ни одно прошлое решение не сдвигается. А поскольку движок побитово идентичен каноническому ядру живого бота, доказательство — это свойство живой системы, а не только бэктеста.

Минное поле мультитаймфрейма

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

Мультитаймфреймовая (multi-TF) стратегия принимает решение по быстрым часам, сверяясь при этом с медленными часами. Наш движок — конкретный пример: тройное TF-правило моментума, где тренд старшего таймфрейма (HTF) и тренд среднего таймфрейма (MTF) гейтят входы, а пересечение младшего таймфрейма (LTF) их триггерит. На базовых 1m-часах вход срабатывает только тогда, когда пересечение HMA/HMA3 на 1m совпадает по направлению с трендом 1h и трендом 15m; выход срабатывает, когда тренд старшего таймфрейма разворачивается против позиции, либо LTF пересекает обратно.

Опасность носит структурный характер. На каждый из этих показателей старшего таймфрейма нужно отвечать на каждом 1m-баре — 60 раз в час, — и каждый ответ должен использовать только то, что живой бот знал бы в этот момент. Как только хотя бы одно из этих примерно 180 обращений на бар (три таймфрейма, тренды входа и выхода, гейты разделения) касается бара, который еще не напечатан, — у вас look-ahead. Площадь поражения для ошибки на единицу огромна, и, в отличие от утечки в исполнении, она не выдает себя сдвигом исполнения.

У формирующегося бара еще нет финального close

Вот точная ловушка. Допустим, сейчас 10:37, и вы принимаете решение по только что закрывшемуся 1m-бару. Ваше правило хочет знать тренд 1h. Часовая свеча, покрывающая 10:00–11:00, все еще формируется — ее финальный close станет известен только в 10:59:59. Что вы на самом деле знаете в 10:37? Только текущий (running) close этой свечи на данный момент, а это и есть close бара 10:37. Финальный close свечи — на 23 минуты в будущем.

Наивный мульти-TF бэктест делает нечто с виду совершенно безобидное: он ресемплирует весь ряд 1m в 1h один раз, заранее, а затем для каждого 1m-бара читает "close 1h". Но значение, которое он читает для каждого 1m-бара между 10:00 и 11:00, — это финальный close 10:00–11:00, число, которого в реальном времени не существует до конца часа. Каждое решение внутри часа незаметно получает доступ к будущему на срок до 59 минут. И это не мелкая утечка: close старшего таймфрейма — едва ли не самый сильный из возможных предикторов ближайшей будущей доходности 1m, которой вы собираетесь торговать, так что утечка этого значения близка к чтению ответов из решебника. Это канал утечки через индикатор из таксономии, только усиленный: подглядывание — это не один бар, а вплоть до целого периода HTF.

Тест сдвига на один бар из предыдущей статьи этого не ловит. Можно сдвинуть исполнение на один бар позже, а ресемплированный ряд 1h все равно останется загрязненным — утечка живет в том, как построен индикатор, а не в том, когда вы совершили сделку.

Правило живого бота, воспроизведенное точно: семантика closed-bar

Буфер старшего таймфрейма на баре i: ряд close уже закрытых свечей, за которым следует одна формирующаяся свеча со значением текущего базового close, а все еще скрытый финальный close перечеркнут

Правильное правило — то, которое уже исполняет живой бот. В нашем коде это небольшой класс RunningCandleBuffer, перенесенный дословно из живого тик-симулятора. Он потоково собирает базовые бары в свечи фиксированного периода и на каждом базовом баре вычисляет индикатор HTF из совершенно конкретного массива:

all_closes = np.array(self.closes + [self.current_close], dtype=np.float64)

Прочитайте это буквально. self.closes — это финальные close свечей, которые уже закрылись: свеча добавляется в список только тогда, когда пересечена новая граница периода, и хранимое значение — это close последнего базового бара внутри периода (candle_buffer.py:39–44). Формирующаяся свеча дает ровно одно число, self.current_close, — это текущий (running) close, close самого последнего базового бара, base_close[i]. Это величина, известная на баре i по определению. Финальный close формирующейся свечи не используется никогда, потому что его еще не существует.

Так что индикатор HTF в 10:37 видит [..., close(свеча 9:00), close(10:37 на данный момент)]. Когда печатается 10:38, последний слот обновляется до close(10:38). Когда пересекается 11:00, close(10:59) становится финальным значением только что закрывшейся свечи, и открывается новый формирующийся слот. Ни в один момент решение внутри часа не касается финального close 10:00–11:00. Это и есть семантика closed-bar: закрытые свечи дают завершенные close, формирующаяся свеча дает только свой текущий close.

Наш быстрый движок (engine_multitf.py) — это векторизованная, скомпилированная через numba переработка того же самого. Вместо Python-цикла с растущим списком он заранее вычисляет для каждого базового бара i, сколько свечей полностью закрылось (n_closed[i]), и выкладывает окно HMA/HMA3 как [close закрытых свечей…, base_close[i]] — текущий close закреплен в последнем слоте (engine_multitf.py:168–169). Это та же самая математика, развернутая ради скорости по трем таймфреймам с гейтами направленного разделения. Контракт явный: значение на баре i зависит только от base_close[0..i].

Это и есть утверждение. Остаток статьи — про то, как мы это доказываем, потому что утверждение в докстроке не стоит ничего.

Паритет необходим, но не достаточен

Векторизованный numba-движок, разворачивающий потоковый класс в явные циклы, — это как раз то место, где плодятся ошибки на единицу. Поэтому первый барьер — это тест побитового паритета против канонического эталона на реальном срезе данных: ETHUSDT 1m, январь–февраль 2024, 86,400 баров.

Мы проверяем две независимые вещи против двух независимых эталонов:

  • Индикаторы и пересечения против RunningCandleBuffer, прогнанного бар за баром. Для каждого таймфрейма мы воспроизводим живой класс на всех 86,400 барах и сравниваем события пересечения — бар, направление, разделение — на точное совпадение, плюс значения HMA/HMA3 с плавающим допуском (эталон использует np.dot, движок — явные циклы, поэтому порядок суммирования отличается на ~1e-15). Пересечения совпадают точно: 408 пересечений на HTF (1h, длина HMA 21), 2,792 на MTF (15m/14), 3,691 на LTF (1m/50). Ни одно событие пересечения не отличается по бару или направлению.
  • Сделки против независимой симуляции торговых правил на чистом Python, управляемой собственными пересечениями эталона. Это воспроизводит цикл живого бэктеста — тренды входа гейтят входы, разворот тренда выхода или противоположное пересечение LTF закрывает позицию, исполнение по open[i+1], комиссия 0.09% за раунд-трип, принудительное закрытие на последнем баре — без всей numba-механики движка. Затем мы сравниваем сделка за сделкой: бары входа/выхода, стороны, цены входа/выхода, PnL, причины выхода и суммарное активное время в позиции.

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

Поле за полем сделки идентичны: 466 сделок для двойной конфигурации, 211 сделок для тройной, при этом суммарный PnL совпадает до 1e-12, и все поля каждой сделки равны в пределах допуска. Две реализации, не имеющие общего кода, — скомпилированный векторизованный движок и наивный Python-цикл на пересечениях третьей реализации — дают одинаковые сделки до последнего знака.

Это сильный результат, но это не доказательство отсутствия look-ahead. Паритет говорит, что быстрый движок добросовестно воспроизводит эталон. Если бы утечка была в самом эталоне — если бы RunningCandleBuffer подглядывал, — паритет добросовестно воспроизвел бы утечку и прошел бы. Согласие между реализациями говорит вам, что они одинаковые, а не что они каузальные. Для доказательства каузальности нужен тест другого рода — тот, что напрямую спрашивает движок, может ли прошлое видеть будущее.

Проба со сдвинутым будущим: настоящее доказательство

Вертикальный разрез на 60 процентах ряда: все, что справа, умножено и встряхнуто, все, что слева, заморожено побитово идентичным

Определение look-ahead операциональное, так что и тестировать его нужно операционально. Look-ahead означает, что прошлое решение зависит от будущих данных. Обратное утверждение — это тест, который можно провести: если вы меняете будущее и хоть одно прошлое решение сдвигается, значит прошлое читало будущее. Так что меняйте будущее — грубо — и наблюдайте за прошлым.

Выберите точку разреза j на 60% ряда (бар 51,840 из 86,400). Возмутите каждый бар начиная с j: умножьте все будущие close и open на 1.05. Пересчитайте весь стек сигналов для всех трех таймфреймов на возмущенных данных. Затем убедитесь, что все строго до j побитово идентично невозмущенному прогону:

j = int(n * 0.6)                       # bar 51,840
cl2 = cl.copy(); cl2[j:] *= 1.05       # shove the future up 5%
op2 = op.copy(); op2[j:] *= 1.05

base = [precompute_tf_signals(cl,  ts, p, L) for (p, L) in tf_params]
pert = [precompute_tf_signals(cl2, ts, p, L) for (p, L) in tf_params]

for s0, s1 in zip(base, pert):
    assert eq_nan(s0.hma[:j],  s1.hma[:j])      # HMA identical, NaNs included
    assert eq_nan(s0.hma3[:j], s1.hma3[:j])
    assert np.array_equal(s0.cross[:j], s1.cross[:j])   # every cross event
    assert np.array_equal(s0.sep[:j],   s1.sep[:j])     # every separation

Не "близко". Не "в пределах допуска". np.array_equal, где NaN обязаны совпадать с NaN, — каждое значение HMA, каждое значение HMA3, каждый флаг пересечения и каждое разделение на 51,840 прошлых барах должны быть одним и тем же float. Затем то же самое утверждение для сделок: каждая сделка, чей выход приходится раньше j, должна быть неизменной поле за полем. Если сдвиг будущего на 5% меняет хоть один прошлый HMA в двенадцатом знаке после запятой, значит прошлое решение обращалось к будущему, и проба провалена.

Наш движок ее проходит — для всех трех таймфреймов и для обеих симуляций сделок, двойной и тройной. Возмущение 34,560 будущих баров оставляет 51,840 прошлых баров и все сделки, закрывшиеся среди них, в точности такими же, какими они были. Это не согласие между реализациями — это прямая демонстрация того, что информационная граница во времени держится.

Тест, который не может провалиться, не доказывает ничего

Есть способ пройти пробу выше, не доказав вообще ничего: если бы возмущение было пустой операцией — если бы умножение будущего на 1.05 нигде ничего не меняло, — тогда "прошлое не изменилось" было бы тривиально истинным и совершенно неинформативным. Зеленая галочка на тесте, который не может провалиться, хуже, чем отсутствие теста, потому что она порождает ложную уверенность. Поэтому проба несет еще два утверждения, которые дают ей зубы.

Будущее должно действительно измениться. Мы проверяем, что возмущение действительно изменило пересечения где-то в [j, n):

assert not np.array_equal(s0.cross[j:], s1.cross[j:])   # probe has teeth

Теперь результат содержателен: тот же самый сдвиг на 5%, переписавший будущее, оставил прошлое побитово идентичным. Возмущение настоящее, оно распространяется вперед и намертво останавливается на разрезе. Односторонняя утечка — прошлое читает будущее — просочилась бы назад через j; этого не происходит.

Граница проходит ровно по текущему бару — не на один раньше, не на один позже. Более тонкий вид отказа — это движок, который каузален, но устаревший: он игнорирует текущий (running) close текущего бара и реагирует на бар позже (утечки нет, но есть лаг, которого не было бы в живой торговле), либо реагирует на бар раньше (утечка на один бар). Поэтому мы возмущаем один бар j (на 1.02) и проверяем сразу две вещи: прошлое [0, j) не тронуто, и hma[j] реагирует немедленно.

cl3 = cl.copy(); cl3[j] *= 1.02        # nudge exactly one bar
s3 = precompute_tf_signals(cl3, ts, p_ltf, L_ltf)
assert eq_nan(s0.hma[:j], s3.hma[:j])  # nothing before j moves
assert s0.hma[j] != s3.hma[j]          # bar j reacts on the same bar

Это точно фиксирует границу. Текущий close формирующейся свечи попадает в индикатор на баре j с нулевой задержкой и нулевым забеганием вперед: бар j видит свой собственный close мгновенно, а ни один более ранний бар не видит его вообще. Это и есть та самая грань, на которой должна сидеть семантика closed-bar, и тест подтверждает, что движок сидит именно на ней.

Вот полный барьер — все 25 проверок, которые стоят между этим движком и сфабрикованным бэктестом:

Группа Что проверяет каждая проверка Число
Индикаторы и пересечения (×3 таймфрейма) события пересечения точно совпадают с RunningCandleBuffer; разделение в момент пересечения; значения HMA/HMA3 (rtol 1e-9) 9
Сделки (двойная + тройная) количество сделок; поле за полем идентичны; суммарный PnL до 1e-12; активное время в позиции 8
Проба со сдвинутым будущим (двойная + тройная) прошлые сигналы побитово неизменны; проба не беззубая (будущее действительно изменилось); сделки до j неизменны; возмущение одного бара локализовано 8
Итого 25

Первые две группы устанавливают, что быстрый движок и есть живой эталон. Третья устанавливает, что эталон каузален. Нужны все три: быстрый движок с утечкой, эталон с утечкой, который каузален, и каузальный движок с лагом — это три разных вида отказа, и барьер исключает каждый из них.

Почему проба безразлична к таймфрейму

Элегантность пробы со сдвинутым будущим в том, что она не знает и не заботится о том, где могла бы жить утечка. Она вообще не упоминает таймфреймы, ресемплирование или границы свечей. Она спрашивает только одно: сдвигает ли возмущение будущего прошлое? Именно это делает ее правильным инструментом для мульти-TF утечки, которую пропускает тест сдвига исполнения.

Рассмотрим напрямую наивный баг с ресемплированием всего ряда целиком. Если бы поток 1h строился ресемплированием всего ряда заранее, то "close 1h", прочитанный на баре j-1000 (глубоко внутри часа, который закрывается после j), был бы финальным close свечи, чей финальный close зависит от баров на уровне j и дальше. Умножьте будущее на 1.05, и этот финальный close изменится — значит, индикатор HTF на j-1000 изменится, гейт на j-1000 изменится, и прошлое решение сдвинется. Проба загорелась бы на потоке HTF мгновенно, на баре на тысячу шагов раньше разреза.

Поток HTF нашего движка не сдвигается, потому что на j-1000 формирующаяся свеча дает только base_close[j-1000] — прошлый close, — а финальный close свечи попросту не запрашивается, пока не пройдена граница. Проба слепа к механизму и все равно ловит баг, а это именно то, что нужно от доказательства: оно ограничивает поведение (ни одно прошлое решение не зависит от будущих данных), а не проверяет реализацию (правильно ли мы проиндексировали ресемплирование?). Торгует поведение; реализация — это то, что вы надеетесь ему соответствует.

Бэктест и живая торговля разделяют одну истину

Две панели, разделяющие один общий стержень с подписью RunningCandleBuffer: свип бэктеста с одной стороны, живой бот с другой, оба подключены к одному и тому же ядру

Есть еще одна причина, почему это важнее типичного аудита бэктеста. Эталон, против которого доказывается движок, — RunningCandleBuffer — это не тестовая заглушка, написанная, чтобы бэктест выглядел хорошо. Это собственная логика свечей живого бота, дословно перенесенная из тик-симулятора, который работает в продакшене. Правило closed-bar, которое проверяет проба, — это правило, которое живой бот исполняет бар за баром.

Так что барьер паритета выполняет двойную работу. Он доказывает, что быстрый движок совпадает с эталоном, а поскольку эталон и есть живое ядро, он доказывает, что быстрый движок совпадает с живой торговлей. Предыдущая статья предупреждала, что утечка — самое чистое объяснение разрыва паритета бэктест-лайв: живой бот — единственное место, где механически невозможно подглядывать, поэтому бэктест, который подглядывает, расходится с реальностью в тот момент, когда выходит в продакшен. Здесь этот разрыв закрыт по построению: бэктест и бот разделяют один буфер свечей, одно правило пересечения, одно определение "известно на баре i". Число, которое оптимизирует поиск, — это число, которое бот действительно может торговать.

В этом и весь смысл доказывать отсутствие look-ahead, а не предполагать его. Мульти-TF поиск прогоняет тысячи конфигураций; если бы движок утекал, поиск нашел бы конфигурацию, эксплуатирующую утечку наиболее агрессивно, и вручил бы вам сфабрикованного победителя — тот самый отказ, который таксономия измерила как Sharpe 15 на чистом шуме. Именно проба позволяет вам доверять тому, что победитель настоящий, прежде чем подключать его к капиталу.

Что проба доказывает, а что нет

Строгость по отношению к тесту работает в обе стороны, так что нужно быть точным насчет его границ. Проба со сдвинутым будущим доказывает одно конкретное свойство: на этих данных ни одно решение о сигнале или сделке на баре j или раньше не зависит ни от одного бара после j — информационная граница во времени держится по всему пути индикатора, пересечения и сделки. Это именно та мульти-TF утечка, которую мы намеревались устранить, и это то свойство, которое ревью кода установить не может.

Она не доказывает, что у стратегии есть эдж. Полностью каузальный движок может честно терять деньги, и проба об этом молчит — и так и должно быть; доказательство отсутствия утечки и поиск реального эджа — это разные задачи, и их смешение — это как раз то, из-за чего утекающие бэктесты попадают в продакшен. Она не покрывает нетемпоральные искажения: survivorship по инструменту, селективное искажение от прогона пробы только после того, как движок уже выглядел хорошо, или слишком добрую модель комиссий. И сама по себе она не доказывает, что живые исполнения совпадают с исполнениями бэктеста — проскальзывание и задержка это реальные разрывы, которых проба не видит, потому что она работает на пути принятия решения, а не на площадке исполнения. Этот разрыв закрывает отдельный факт: ядро свечей движка побитово идентично ядру живого бота.

Одна честная оговорка о самом устройстве пробы: она режет по единственному j (60% ряда). Свойство, которое она проверяет, равномерно по j — в баре 51,840 нет ничего особенного, — поэтому один разрез является справедливым тестом структурного свойства, но параноидальная версия прогоняла бы j по всему ряду. Мы считаем один удачно выбранный разрез плюс проверку локализации на одном баре достаточными, потому что утечка, которая прячется от возмущения будущего на 34,560 барах, но проявляется на каком-то другом разрезе, была бы очень странным багом. Смысл в том, чтобы знать границы своих доказательств, а не притворяться, что один тест — это универсальный квантор.

Выводы

  1. Мультитаймфреймовые стратегии протекают через формирующийся бар, а не через исполнение. Решение внутри незавершенного периода HTF должно использовать только текущий close этой свечи (последний базовый close), но никогда — ее финальный close. Бэктесты с ресемплированием всего ряда целиком вручают каждому внутрипериодному решению доступ к будущему длиной до целого периода HTF.
  2. Тест сдвига на один бар до этой утечки не дотягивается. Он ловит look-ahead в исполнении; мульти-TF утечка живет в том, как построен индикатор. Нужна другая проба.
  3. Воспроизведите живое правило точно, затем докажите его. Мы пересобрали семантику closed-bar RunningCandleBuffer бота в виде векторизованного движка и поставили за ней барьер из 25 проверок: пересечения точно совпадают с эталоном (408 / 2,792 / 3,691), сделки идентичны поле за полем (466 двойных, 211 тройных), PnL до 1e-12.
  4. Паритет необходим, но не достаточен. Совпадение с эталоном доказывает, что вы одинаковые, а не что вы каузальны. Утекающий эталон, добросовестно воспроизведенный, все равно протекает.
  5. Проба со сдвинутым будущим — это настоящее доказательство. Возмутите каждый бар на j или позже; убедитесь, что каждый сигнал и сделка до j побитово не изменились. Если будущее может сдвинуть прошлое, у вас look-ahead.
  6. Дайте пробе зубы. Убедитесь, что будущее действительно изменилось (возмущение — не пустая операция) и что точечный сдвиг одного бара реагирует на этом же баре (нет утечки, нет лага). Тест, который не может провалиться, не доказывает ничего.
  7. Когда бэктест и живая торговля разделяют одно ядро, доказательство переносится. Поскольку движок побитово идентичен логике свечей живого бота, отсутствие look-ahead — это свойство и живой системы тоже, а разрыв паритета бэктест-лайв закрывается по построению.

Предыдущая статья показала, как утечка в одну строку фабрикует Sharpe 15. Эта показывает противоположную дисциплину: не "как утечки вас обманывают", а как доказать, механически и на реальных данных, что конкретный движок не протекает. Возмутите будущее. Если прошлое не дрогнуло, значит вы торгуете в настоящем.

Дисклеймер: Информация в этой статье предоставлена исключительно в образовательных и ознакомительных целях и не является финансовым, инвестиционным или торговым советом. Торговля криптовалютами сопряжена с высоким риском убытков.

Авторы

Евгений Соловьёв
Евгений Соловьёв

Инженер торговых систем

Разработка торговых ботов с 2017 года: межбиржевой арбитраж (подключал до 30 бирж), парный арбитраж на коинтеграции между спотом и фьючерсами, скальпинг, фронтраннинг, торговля по новостям, сентиментный анализ, трендовые алгоритмы, а также алгоритмы управления и балансировки портфелей. Делает выставление ордеров до 1 мс, warehouse для big data, бэктестинг-движки, AI-агентов и интерфейсы для ботов (в т.ч. open-source profitmaker.cc). Стек: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, архитектура.

Newsletter

Будьте в курсе событий

Подпишитесь на нашу рассылку, чтобы получать эксклюзивную аналитику по AI-трейдингу и обновления платформы.

Мы уважаем вашу конфиденциальность. Отписаться можно в любой момент.