Той самий коміт, ті самі 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-годинний бар із нульовою кількістю угод у вікні ретроспективного аналізу, тож відношення close до попереднього close дорівнювало одиниці, а логарифм — нулю. Не округлили до нуля. Саме нуль.
Два символи поділили п’яте місце. До вибірки потрапляли п’ять найкращих. Який із двох пройде, залежало від порядку, у якому їх залишило сортування, а воно працювало не так, як я припускав.
Нічия, нестабільне сортування і те, що я ігнорував два роки
Ранжування виконувалося через групову агрегацію, а фрейм для неї формувався словниковим включенням із множини символів, яку для кожного бару перебудовував асинхронний запит даних. Порядок обходу множини змінюється залежно від хеш-зерна, а Python рандомізує хеш-значення рядків для кожного процесу, якщо не зафіксувати PYTHONHASHSEED. Тому порядок рядків до сортування відрізнявся між запусками, і через нестабільне сортування за ключем із нічиєю результат нічиєї щоразу був іншим.
Такі нічиї — не рідкісний збіг. Вони закладені в саму структуру. Будь-де, де ознака насичується або обрізається, виникає точна рівність: бари з нульовим обсягом дають рівно нульову дохідність, обрізаний z-рахунок фіксується на ±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, за день до шестиденного тренду, на якому сформувалася приблизно третина загального PnL запуску. Запуск A був у ринку весь цей рух, а запуск B увійшов із запізненням на одне ребалансування.
Різниця дохідності: 3.4 пункту. Розрив Sharpe більший, ніж можна припустити за різницею дохідності, бо переставлені угоди запуску B припали на волатильніший відрізок: знаменник зріс, а чисельник зменшився. Невелика причина, два підсилювачі.
Якщо у вас фіксований номінал позицій, а входи не залежать від поточних позицій, ви значно краще захищені від такого ефекту. Але більшість цікавих стратегій не відповідають жодній із цих умов.
П’ять місць, де це трапляється на практиці
| Джерело | Ознака | Виправлення |
|---|---|---|
Незафиксований PYTHONHASHSEED з обходом множини/словника, що визначає порядок сортування | Нічиї розв’язуються по-різному між запусками; перше розходження на конкретному барі | Зафіксувати зерно; сортувати за явним додатковим ключем (символом), щоб нічиї розв’язувалися детерміновано |
Нестабільне сортування за ключем із нічиєю (quicksort стандартний варіант у NumPy/pandas) | Те саме, що вище, але зберігається навіть після фіксації зерна | kind="stable", або зробити ключ повним |
| Нефіксований генератор випадкових чисел у бутстрепі, перемішуванні train/test або штучному джитері виконання | Відхилення в усьому запуску, без чіткої точки розходження | Задати окреме явне зерно для кожного компонента й записувати його в маніфест запуску |
| Паралельне додавання чисел із рухомою комою (порядок сумування залежить від кількості потоків) | Розбіжності в останніх кількох бітах, зазвичай нешкідливі, доки не вплинуть на порівняння з порогом | Фіксувати кількість потоків у дослідницьких запусках; ніколи не порівнювати числа з рухомою комою за допомогою == на межі ухвалення рішення |
| Незафиксовані версії бібліотек | Відтворювано сьогодні, але не в листопаді | Додати хеш lockfile до маніфесту разом із хешем знімка даних |
Четвертий рядок не так часто має значення, як побоюються люди, а перший постійно створює проблеми.
Бітова відтворюваність — це інструмент, а не чеснота
Детермінованість потрібна, щоб після зміни одного рядка коду можна було пов’язати різницю в кривій капіталу саме з ним. У цьому й уся суть. Тепер кожен запуск агента в Stratmill створює маніфест із хешем знімка даних, хешем lockfile та всіма зернами, а повторний запуск, який не відтворює попередню криву біт-у-біт, вважається невдалим збиранням, а не приводом для цікавості.
Але щойно ви навчитеся відтворювати результати, навмисно поруште детермінованість. Запустіть усе 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, який вам подобається найменше. Це справжні критерії для розв’язання нічиєї. Перевага в Sharpe на 0.15 — ні.
Ще одна річ, яку варто зробити, перш ніж довіряти будь-яким результатам. Запустіть бектест двічі просто зараз, порівняйте капітал за кожним баром і з’ясуйте, до якої групи належить ваш результат: біт-у-біт однаковий чи з розривом у 0.15. Це експеримент на 15 хвилин, який покаже, яка частина історії ваших досліджень вимірювала стратегію, а яка — хеш-зерно.
← Усі статті

