Один и тот же коммит, те же parquet-файлы, та же машина, два запуска с интервалом в час. Sharpe 1.34 и Sharpe 1.19. Общая доходность 41.2% и 37.8%. Число сделок — 1,418 и 1,421. И кривые капитала совпадали до каждой отображаемой десятичной цифры на протяжении 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. Этот эксперимент займёт пятнадцать минут и покажет, какую долю истории ваших исследований вы потратили на измерение стратегии, а какую — на измерение хеш-сида.
← Все статьи


