Утечка на 3.5 часа вперёд: как модель обманывала саму себя полтора месяца — и как мы это поймали

Полтора месяца ML‑ансамбль в моей торговой системе показывал на бэктесте AUC 0.78 — солидное число для предсказания движения цены по 15-минутным свечам. В бою та же система в лучшем случае выходила в ноль. Разрыв между «отлично работает на истории» и «ничего не работает в реальности» — самый частый и обманчивый симптом в прикладном ML на временных рядах. Обычно за ним стоит одно из трёх: недообучение на новых условиях, изменившийся рынок или утечка данных.
Первые две гипотезы мы перебирали месяц. Виновата оказалась утечка — и она была устроена настолько тихо, что для её поимки пришлось изобрести отдельный диагностический тест.
Ниже — анатомия бага, способ его детекции, цена ошибки и методика проверки, ставшая обязательной для любой новой гипотезы в проекте.
Симптом
Система — ансамбль из шести моделей (LightGBM, LSTM, Transformer, TCN, PatchTST, iTransformer), предсказывающих вероятность роста цены криптопары на горизонте нескольких 15-минутных свечей.
На входе — 32 признака: технические индикаторы, объём, время суток, а также группа признаков со старших таймфреймов (1h/4h) — тренд, моментум, RSI, посчитанные на агрегированных свечах и подмешанные к 15-минутным барам.
На бэктесте (11 месяцев истории): уверенный AUC 0.74–0.78 и лифт топ-10% сигналов около +25 п.п. над базовой линией.
В бою (paper‑trading “турнир” в реальном времени): эдж на глазах схлопывался до нуля.
Первая мысль — слишком короткое окно инференса: в боевом цикле подавались последние 500 свечей истории, а не весь датасет. Мы прогнали инференс с окнами 500, 1000, 2000, 4000 свечей — результат не шелохнулся. Гипотеза отпала, а разрыв остался необъяснимым.
Инструмент, без которого искать дальше не имело смысла
К этому моменту в проекте уже накопился опыт: находки, сделанные «на глаз» по паре удачных недель, регулярно оказывались шумом. Поэтому перед дальнейшим поиском я зафиксировал три обязательных фильтра:
Лифт вместо абсолютной метрики. Win‑rate отобранных сигналов сравнивается с win‑rate всех баров того же среза, а не с абстрактным порогом в 50%. Иначе устойчивый аптренд на рынке модель запишет себе в актив.
Split‑half по времени. Эффект обязан воспроизводиться на обеих половинах истории независимо. Если фича даёт профит только на одной половине — это подгонка.
Контроль на шум (permutation test). Перемешать исходы сделок случайным образом и повторить процедуру отбора признаков. Это показывает порог ложных открытий: сколько «эджа» рождает перебор сам по себе. На 31 проверенной гипотезе контрольный порог составил +11.3 п.п. — шесть признаков, прошедших первичный отбор, пришлось выбросить как артефакт множественного тестирования.
Эти проверки не нашли баг сами, но создали санитарный барьер, без которого копать дальше было бы бесполезно.
Анатомия утечки
Признаки старших таймфреймов строились стандартной конструкцией:
# АНТИПАТТЕРН: неявная утечка будущего
htf4_trend_s = df_t['close'].resample('4h').apply(compute_trend)
df['htf4_trend'] = htf4_trend_s.reindex(df.index, method='ffill')Что здесь происходит:
resample('4h')агрегирует 15-минутные бары в 4-часовые корзины и по умолчанию маркирует результат началом интервала (метка12:00для интервала12:00–16:00).Функция
compute_trendвнутри корзины считает значение по всем барам вплоть до15:45.reindex(..., method='ffill')протягивает это значение вперёд на все 15-минутные бары корзины:12:00,12:15,12:30и так далее.
Временная шкала 4-часовой корзины [12:00 - 16:00]:
12:00 12:15 12:30 ... 15:30 15:45 16:00
|-------|-------|-------------|-------|-------|
^ ^
| |
\--- Бар 12:15 УЖЕ получил ---/
значение по закрытию 15:45
(утечка на 3.5 часа вперёд)В итоге бару в 12:15 доставалось значение признака, которое физически станет известно рынку только в 15:45.
Позиционный тест: как доказать заглядывание в будущее
Чтобы перейти от догадки к доказательству, я сравнил значение признака, посчитанное на полной истории, со значением того же признака, посчитанным на локальном срезе, обрезанном ровно по текущий бар :
Затем сгруппировал разницу по номеру 15-минутного бара внутри 4-часовой корзины (0 — первый бар корзины, 15 — последний):
Позиция бара в корзине | Расхождение (полная история / срез) |
0 (первый, 12:00) | 0.008089 |
8 (середина, 14:00) | 0.004799 |
15 (последний, 15:45) | 0.000000 |
Расхождение падает к нулю на последнем баре корзины — она уже полностью закрыта, заглядывать больше некуда — и растёт к первому, где горизонт утечки максимален (3 часа 45 минут). Такой профиль зависимости расхождения именно от позиции внутри корзины — однозначная сигнатура утечки через resample/reindex, и с тех пор это первый тест, который я прогоняю на любом новом признаке со старшего таймфрейма.
Масштаб ущерба
Признаки старших таймфреймов — не второстепенная деталь: на них приходилось около 41% суммарной важности моделей (htf4_trend — 18.3%, htf4_mom — 11.2%, htf4_rsi — 6.8%). Модель во время обучения видела признаки, посчитанные «с будущим»; в бою у неё будущего не было — и она получала честные, более шумные версии тех же чисел. Она училась на одном распределении, а работала на другом.
После фикса цифры пересчитались:
Метрика | С утечкой | Честный расчёт | Дельта |
AUC (holdout, 11 месяцев) | 0.7379 | 0.6345 | −0.1034 |
AUC (июнь‑август) | 0.7263 | 0.6485 | −0.0778 |
Лифт топ-10% сигналов | +24.7 п.п. | +10.3 п.п. | −14.4 п.п. |
Не ноль — реальный сигнал в данных остался, просто он вдвое скромнее, чем казалось полтора месяца.
Фикс и то, что пришлось выбросить честно
Фикс на удивление скромный — один .shift(1) перед reindex на каждую из шести htf‑серий, чтобы бар брал последнюю полностью закрытую корзину, а не текущую недосчитанную:
# КОРРЕКТНЫЙ РАСЧЁТ: берём только закрытые корзины
htf4_trend_s = df_t['close'].resample('4h').apply(compute_trend).shift(1)
df['htf4_trend'] = htf4_trend_s.reindex(df.index, method='ffill')Позиционный тест после фикса даёт расхождение ровно ноль на любой позиции — это и есть критерий, что утечка закрыта, а не просто уменьшена.
Дальше — неприятная часть постмортема, которую легко пропустить, но пропускать не стоит:
Более ранний вывод «переобучение модели на свежих данных даёт +0.05 AUC против боевой версии» обесценился: обе модели сравнивались на отравленных признаках.
Полный аудит из 35 признаков, сделанный неделей раньше, показывал AUC 0.73–0.78 — все цифры завышены.
5.5 часов GPU‑времени на переобучение прошли на признаках с утечкой — впустую.
Отдельная экспериментальная торговая ветка (long‑only, с найденной «оптимальной геометрией выходов» и holdout win‑rate 59.5%) была закрыта полностью: на честных признаках переобучение не проходило валидацию ни на одной из 72 проверенных комбинаций параметров. Найденный ранее эдж был утечкой, а не сигналом.
Из хорошего: выводы, сделанные не по признакам модели, а по исходам реально смоделированных сделок — какие пары отключить, насколько широким должен быть стоп‑лосс — утечка не затронула. Они считались на цене входа и выхода, а не на значении признака. Эта линия проверки уцелела именно потому, что не зависела от скомпрометированного источника.
Что переносится за пределы этого проекта
Если вы строите признаки для ML на временных рядах ресемплингом на старший таймфрейм — это частая и почти незаметная в коде ошибка: resample().reindex(method='ffill') без явного сдвига практически гарантированно даёт утечку такого рода, а бэктест на полной истории её никак не проявляет, потому что и обучение, и валидация считаются одним и тем же (уже отравленным) способом. Проявляется она только при сравнении с честным сокращённым инференсом — то есть именно там, где вы её меньше всего будете искать, потому что бэктест «и так всё показывает».
Позиционный тест дёшево стоит и ловит целый класс подобных ошибок. Минимальная версия, которую можно прогонять на любой новый признак до того, как он попадёт в обучение:
def check_htf_leakage(df_full, compute_fn, sample_size=500, atol=1e-9):
"""
Сравнивает признак, посчитанный на полной истории,
со значением того же признака на срезе, обрезанном по каждый бар.
Ненулевое расхождение = заглядывание в будущее.
"""
full = compute_fn(df_full)
idxs = np.random.choice(len(df_full) - 1, size=sample_size, replace=False)
max_leak = 0.0
for idx in idxs:
val_full = full.iloc[idx]
val_pit = compute_fn(df_full.iloc[: idx + 1]).iloc[-1]
max_leak = max(max_leak, abs(val_full - val_pit))
assert np.isclose(max_leak, 0.0, atol=atol), (
f"Обнаружена утечка данных, макс. расхождение: {max_leak:.6f}"
)Это дорого по вычислениям (пересчёт признака на каждом срезе), поэтому в проекте он не гоняется на всём датасете при каждом обучении — только точечно, на выборке баров, при добавлении или изменении любого признака со старшего таймфрейма. Пары минут машинного времени против недель работы над стратегией, обречённой на провал в продакшене, — сделка того стоит.
Я делаю ML‑ и автоматизационные системы, которые доезжают до продакшна, а не только до ноутбука с бэктестом — среди прочего эта же торговая система целиком, включая турнир моделей, риск‑движок и переход на перцентильные пороги входа после серии похожих находок. Буду рад обсудить в комментариях, как вы отслеживаете неявный look‑ahead bias в своих пайплайнах.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.