11 Сентября 2026 · воспроизводимость

Тот же код, те же данные, два разных значения Sharpe: аудит недетерминированности, необходимый вашему бэктесту

Тот же код, те же данные, два разных значения Sharpe: аудит недетерминированности, необходимый вашему бэктесту

Один и тот же коммит, те же parquet-файлы, та же машина, два запуска с интервалом в час. Sharpe 1.34 и Sharpe 1.19. Общая доходность 41.2% и 37.8%. Число сделок — 1,418 и 1,421. И кривые капитала совпадали до каждой отображаемой десятичной цифры на протяжении 11,205 баров, прежде чем разошлись.

0.15Разница Sharpe при одинаковых входных данных
3 / 1,418различающиеся сделки
11,205баров совпадения до расхождения

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

Стратегия: кросс-секционный моментум по 30 бессрочным контрактам USDⓈ-M с наибольшим объёмом, ребалансировка раз в 4 часа, лонг по пяти лидерам 12-часовой доходности, шорт по пяти аутсайдерам, история за 18 месяцев, комиссии и funding учитываются для каждого плеча.

Находим бар, на котором запуски разошлись

Если записывать капитал на каждом баре с полной точностью, на это уйдёт около четырёх минут. Сохраните оба запуска в CSV с (bar_index, equity, open_positions_hash), загрузите их и найдите первый индекс, где они расходятся. Этот скрипт у нас уже был написан для другой ошибки — только поэтому я не потратил на поиск целое утро.

Бар 11,206, 2025-03-14T08:00 UTC. На предыдущем баре капитал совпадал до 13 значащих цифр. На баре 11,206 хеши позиций различаются: в запуске A лонг по SOL, в запуске B — по AVAX. Направление и номинал одинаковы, инструмент разный. Всё последующее — следствие этого расхождения.

Тогда я вывел входные данные ранжирования на этом баре для обоих запусков. Они совпадали. Побайтно одинаковые: те же 30 инструментов и те же 30 значений. Две оценки были равны 0.0. Ровно нулю: по обоим инструментам в окне ретроспективного анализа был бар за 4 часа с нулём сделок, поэтому отношение цены закрытия к предыдущей цене закрытия получилось равным единице, а логарифм — нулю. Не округлилось до нуля. Именно ноль.

Два инструмента разделили пятое место. В выборку попадали первые пять. Какой из двух окажется в списке, зависело от того, в каком порядке их оставила сортировка, а сортировка работала не так, как я предполагал.

Ничья, нестабильная сортировка и то, на что я не обращал внимания два года

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

Такие ничьи — не редкое исключение. Они заложены в структуру данных. Везде, где признак достигает предельного значения или обрезается, возникают точные совпадения: бары с нулевым объёмом дают ровно нулевую доходность, ограниченный z-score фиксируется на ±3.0, ранговое преобразование с малым числом уникальных значений создаёт множество совпадений, а булев фильтр присваивает прошедшим значения 1.0. За 18 месяцев с 4-часовыми барами в этом запуске было 47 баров с ничьей на границе отбора. В трёх случаях изменился выбранный набор инструментов. В остальных случаях ничья возникала между двумя инструментами, которые оба уже входили в набор или оба в него не попали.

Около дня я был уверен, что недетерминированно работает загрузчик данных, потому что это звучало как интересное объяснение. Но дело было не в нём. Дело оказалось в множестве, ничьей и предположении о стабильности сортировки, которое никто не зафиксировал.

Почему три сделки дают разницу Sharpe в 0.15

С этим многие спорят. Ответ в том, что бэктест с размером позиции как долей капитала — система с зависимостью от траектории. Если на каждое плечо приходится 8% текущего капитала, то разница в капитале на баре n означает разницу каждого номинала начиная с бара n.

Само по себе первое расхождение стоило немного. Плечо AVAX в запуске B потеряло 2.1% за девять часов, а плечо SOL в запуске A принесло 0.4%. Разница в капитале после этой сделки — 0.21%. Мелочь. Но с этого момента запуски уже не были одной и той же стратегией. Размер позиций немного отличался, поэтому суммы начисленного funding тоже немного разошлись. Кроме того, две последующие ничьи на границе отбора разрешились по-разному: оценки для них уже рассчитывались на основе немного различавшихся удерживаемых позиций. Одна из таких сделок пришлась на 2025-03-27, за день до шестидневного тренда, который принёс примерно треть общей прибыли запуска. Запуск A участвовал в движении целиком, а запуск B вошёл с опозданием на одну ребалансировку.

Разница доходности: 3.4 процентного пункта. Разница Sharpe больше, чем можно предположить по разнице доходности: сделки запуска B сдвинулись на более волатильный период, поэтому знаменатель вырос, а числитель снизился. Небольшая причина, два усилителя эффекта.

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

Пять мест, где это действительно происходит

ИсточникСимптомРешение
Незакреплённый PYTHONHASHSEED и порядок обхода множества или словаря перед сортировкойНичья разрешается по-разному в каждом запуске; первое расхождение возникает на конкретном бареЗакрепите сид; добавьте явный вторичный ключ (инструмент), чтобы ничьи разрешались детерминированно
Нестабильная сортировка при равных значениях ключа (quicksort по умолчанию в NumPy/pandas)То же, что выше, но сохраняется и при закреплённом сидеkind="stable" или задайте полный порядок ключей
Незаданный сид RNG в бутстрепе, перемешивании обучающей и тестовой выборок или искусственном разбросе исполненийДрейф результатов всего запуска без чёткой точки расхожденияЗадайте отдельный явный сид для каждого компонента и запишите его в манифест запуска
Параллельное суммирование чисел с плавающей точкой (порядок суммирования зависит от числа потоков)Различия в нескольких последних битах; обычно они безвредны, пока не повлияют на сравнение с порогомДля исследовательских запусков закрепите число потоков; никогда не сравнивайте числа с плавающей точкой через == на границе принятия решения
Незакреплённые версии библиотекСегодня результат воспроизводим, а в ноябре — уже нетДобавьте в манифест хеш lock-файла вместе с хешем снимка данных

Четвёртая строка беспокоит людей чаще, чем следовало бы; первая же постоянно создаёт проблемы.

Побитовая воспроизводимость — инструмент, а не добродетель

Детерминизм нужен, чтобы при изменении одной строки кода вы могли связать изменение кривой капитала именно с этой строкой. Только для этого. Теперь каждый запуск агента на Stratmill записывает манифест с хешем снимка данных, хешем lock-файла и всеми сидами. Повторный запуск, не воспроизводящий прежнюю кривую побитово, считается неудачной сборкой, а не любопытным случаем.

Но как только добьётесь воспроизводимости, намеренно нарушьте её. Запустите стратегию 64 раза с 64 сидами и посмотрите на разброс:

Диапазон разброса. Одна стратегия, одни данные, 64 варианта с разными сидами для разрешения ничьих и порядка исполнения. Sharpe на уровне p5 — 1.12, медиана — 1.27, p95 — 1.41. Ширина диапазона — 0.29.

Теперь вернитесь к перебору параметров. Лучший набор получил 1.46. Набор на 40-м месте из 96 получил 1.31. Разница между ними — 0.15, то есть половина диапазона. Перебор не ранжировал эти два набора: он взял по одному значению из распределения каждого и отсортировал их.

Этот взгляд изменил наш подход к выбору. Результат перебора даёт ранжирование, только если разница между наборами превышает разброс одного набора. У стратегии с зависимостью от траектории и 1,400 сделками разброс обычно достаточно велик, чтобы превратить верхнюю треть рейтинга в одну большую ничью. В таком случае выбирайте по критерию, который не теряется в этом диапазоне: меньший оборот, меньше параметров, допущение о затратах, которое вы готовы отстоять перед скептиком, или лучшие результаты в наименее удачном для вас фолде walk-forward. Это весомые критерии для разрешения ничьей. Преимущество в 0.15 по Sharpe — нет.

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

детерминизмбэктестингинженерия данныхпереобучениеpython
ПоделитьсяXLinkedInFacebookRedditHacker NewsWhatsAppTelegramEmail
← Все статьи