Strasmore Research
Deep Dives · Matt ConnorBy Matt Connor ·

Бутстреп і довірчі інтервали backtest

Одна крива капіталу є лише однією вибіркою. Довірчий інтервал для Sharpe у backtest може охоплювати нуль. Як його побудувати та читати.

Довірчий інтервал для backtest відповідає на одне запитання: яка частина кривої капіталу зумовлена стратегією, а яка — конкретним відрізком історії, на якому її тестували. Бутстреп backtest формує цей інтервал, багаторазово повторно вибираючи спостереження з ряду дохідностей, перераховуючи статистичний показник для кожної такої вибірки та визначаючи процентилі отриманого розподілу. Sharpe на рівні 1,4, розрахований за один рік щоденних спостережень, має 95-відсотковий інтервал, достатньо широкий, щоб охоплювати нуль. Нижче наведені панелі показують причини цього.

Чому одна крива капіталу є лише одним спостереженням

Backtest дає одне значення для кожної статистики: один коефіцієнт Sharpe, одну річну дохідність, одну максимальну просадку та один відсоток прибуткових угод. Жодне з цих значень не є справжнім показником стратегії. Кожне з них є оцінкою, розрахованою на основі обмеженої кількості торгових днів. Інший відрізок такої самої тривалості дав би інший результат.

На панелі нижче стратегію повністю вилучено з аналізу. Вона показує найпростішу можливу позицію: утримання SPY протягом одного календарного року за раз на основі дохідності від закриття до закриття.

ЗапитОдна позиція, один рік за раз: річний коефіцієнт Шарпа SPY за календарними роками
Точний SQL-код для кожного числа
WITH daily AS
(
    SELECT
        date,
        toFloat64(close) / nullIf(lagInFrame(toFloat64(close), 1)
            OVER (ORDER BY date ROWS BETWEEN 1 PRECEDING AND CURRENT ROW), 0) - 1 AS ret
    FROM global_markets.stocks_daily_aggs
    WHERE ticker = 'SPY'
      AND date >= '2011-12-01'
      AND date <  '2026-01-01'
)
SELECT
    toString(toYear(date))                            AS year,
    count()                                           AS obs_count,
    round(avg(ret) * 252 * 100, 2)                    AS ann_return_pct,
    round(stddevSamp(ret) * sqrt(252) * 100, 2)       AS ann_vol_pct,
    round(avg(ret) / stddevSamp(ret) * sqrt(252), 2)  AS sharpe_ratio
FROM daily
WHERE date >= '2012-01-01'
  AND ret IS NOT NULL
GROUP BY year
ORDER BY year
Run this yourself

Кожен рядок охоплює приблизно 250 торгових сесій, що близько до стандартного торгового року. Позиція в жодному з періодів не змінювалася. У 2012 річний коефіцієнт Sharpe становив 1.06, а в 20250.88; на графіку показано 14 років. Один рік утримання широкого індексу змінює ключовий показник сильніше, ніж більшість читачів вважали б звичайним шумом. Backtest стратегії такої самої тривалості успадковує щонайменше таку саму варіативність. Методика річної ануалізації, використана в цій колонці, описана в нашому посібнику з коефіцієнта Sharpe, а механіку розрахунку періодичної дохідності — у матеріалі як вимірюють місячну дохідність.

Наскільки широким є діапазон навколо Sharpe у backtest?

Стандартна похибка оцінки Sharpe приблизно зменшується пропорційно до квадратного кореня з кількості спостережень. Замість того щоб покладатися на цю формулу, виміряйте розкид безпосередньо. Розділіть двадцять років торгових сесій на невзаємоперекривні вікна фіксованої довжини, розрахуйте річний Sharpe всередині кожного вікна й подивіться, наскільки відрізняються отримані значення.

ЗапитВиміряний розкид коефіцієнта Шарпа за неперекривними періодами SPY, 2006–2025
Точний SQL-код для кожного числа
WITH daily AS
(
    SELECT
        date,
        toFloat64(close) / nullIf(lagInFrame(toFloat64(close), 1)
            OVER (ORDER BY date ROWS BETWEEN 1 PRECEDING AND CURRENT ROW), 0) - 1 AS ret
    FROM global_markets.stocks_daily_aggs
    WHERE ticker = 'SPY'
      AND date >= '2005-12-01'
      AND date <  '2026-01-01'
),
indexed AS
(
    SELECT
        ret,
        row_number() OVER (ORDER BY date) AS i
    FROM daily
    WHERE date >= '2006-01-01'
      AND ret IS NOT NULL
),
blocks AS
(
    SELECT
        w,
        intDiv(i, w)                                            AS blk,
        count()                                                 AS n,
        avg(ret) / nullIf(stddevSamp(ret), 0) * sqrt(252)        AS sharpe
    FROM indexed
    CROSS JOIN (SELECT arrayJoin([21, 63, 126, 252, 504]) AS w) AS ws
    GROUP BY w, blk
    HAVING n = w
)
SELECT
    concat(toString(w), ' sessions')                          AS horizon,
    count()                                                   AS block_count,
    round(quantileDeterministic(0.05)(sharpe, blk), 2)        AS sharpe_p05,
    round(quantileDeterministic(0.50)(sharpe, blk), 2)        AS sharpe_p50,
    round(quantileDeterministic(0.95)(sharpe, blk), 2)        AS sharpe_p95,
    round(quantileDeterministic(0.95)(sharpe, blk)
        - quantileDeterministic(0.05)(sharpe, blk), 2)        AS sharpe_band_width
FROM blocks
WHERE sharpe IS NOT NULL
GROUP BY w
ORDER BY w
Run this yourself

За довжини вікна 21 sessions 5-й–95-й перцентиль виміряних значень Sharpe становить від -3.37 до 6.97. Це розкид у 10.34 пунктів Sharpe за 238 вікон. Якщо збільшити довжину кожного вікна до 504 sessions, розкид скорочується до 1.67 пунктів, але тепер його виміряно лише за 8 вікнами. Одночасно змінюються два показники. Зі збільшенням обсягу вибірки оцінка стає точнішою, але кількість незалежних вибірок, яку може надати історія, різко скорочується. Саме ця суперечність є головною темою.

Як побудувати довірчий інтервал для backtest методом bootstrap

Процедура достатньо коротка, щоб навести її повністю.

  1. Візьміть ряд реалізованої дохідності стратегії: по одному значенню за період, загалом N значень.
  2. Випадково виберіть N значень із цього списку з поверненням. Деякі значення з’являться двічі, а деякі — жодного разу.
  3. Повторно розрахуйте статистику для отриманої вибірки.
  4. Повторіть процедуру кілька тисяч разів і збережіть кожне значення.
  5. Відсортуйте збережені значення та візьміть 2,5-й і 97,5-й перцентилі для 95-відсоткового інтервалу.

Перед кроком 2 зафіксуйте seed генератора випадкових чисел і вкажіть його поруч з опублікованим інтервалом. Bootstrap є оцінкою методом Monte Carlo: два запуски без зафіксованого seed відрізнятимуться в останніх цифрах, а рецензент, який не може повторно розрахувати ваш інтервал, не має способу його перевірити. Такий самий підхід описано в матеріалі відтворюване налаштування backtest.

Середня дохідність і Sharpe без проблем проходять через крок 2, оскільки обидва показники використовують список дохідностей як набір значень. Максимальна просадка працює інакше. Для розрахунку просадки важливий порядок проходження значень. Resample, у якому дохідності розташовані в іншому порядку, може дати максимальну просадку, якої не виникало за жодної послідовності фактичних угод. Водночас застосовувати bootstrap до цього показника все одно доцільно, якщо чітко зазначити, що саме він показує: розподіл просадки для перемішаних історій, а не прогноз наступної просадки. Визначення наведено в матеріалі максимальна просадка.

Чому IID-бутстреп є некоректним для ринкових дохідностей

Крок 2 передбачає, що кожна дохідність є незалежною та однаково розподіленою — це припущення IID. Денна дохідність порушує його так, що це змінює ширину інтервалу. Знакові дохідності мають лише слабку одноденну залежність. Їхні величини кластеризуються: великі рухи часто відбуваються один за одним, а спокійні дні формують послідовності.

ЗапитАвтокореляція першого лага: знакові доходності проти абсолютних доходностей, 2016–2025
Точний SQL-код для кожного числа
WITH daily AS
(
    SELECT
        ticker,
        date,
        toFloat64(close) / nullIf(lagInFrame(toFloat64(close), 1)
            OVER (PARTITION BY ticker ORDER BY date ROWS BETWEEN 1 PRECEDING AND CURRENT ROW), 0) - 1 AS ret
    FROM global_markets.stocks_daily_aggs
    WHERE ticker IN ('SPY', 'MSFT', 'KO', 'XOM', 'JNJ', 'PG')
      AND date >= '2015-11-01'
      AND date <  '2026-01-01'
),
lagged AS
(
    SELECT
        ticker,
        date,
        ret,
        lagInFrame(ret, 1) OVER (PARTITION BY ticker ORDER BY date ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS ret_prev
    FROM daily
    WHERE ret IS NOT NULL
      AND abs(ret) < 0.35
)
SELECT
    ticker,
    count()                                AS obs_count,
    round(corr(ret, ret_prev), 3)          AS return_autocorr,
    round(corr(abs(ret), abs(ret_prev)), 3) AS abs_return_autocorr
FROM lagged
WHERE date >= '2016-01-01'
  AND ret_prev IS NOT NULL
GROUP BY ticker
ORDER BY abs_return_autocorr DESC
Run this yourself

Для SPY автокореляція першого лага абсолютних денних дохідностей становила 0.366 за 2514 сесій, тоді як для знакових дохідностей за ті самі дні цей показник дорівнював -0.133. Порівняйте два стовпчики для будь-якого активу на графіку. Перемішування ряду дохідностей знищує цю кластеризацію, а IID-бутстреп на таких даних дає вужчий інтервал, ніж підтверджує вибірка. Помилка виникає в найменш сприятливому напрямку: вона створює надто позитивне враження про стратегію.

Рухомий block bootstrap і вибір довжини блоку

Рішення полягає в тому, щоб повторно вибирати послідовні блоки, а не окремі дохідності. Виберіть довжину блоку L, випадково вибирайте з поверненням блоки з L послідовних дохідностей і з'єднуйте їх один за одним, доки синтетичний ряд не матиме довжину N. Залежність усередині блоку зберігається: дохідності залишаються в початковому порядку. Штучними є лише стики між блоками.

Довжина блоку створює компроміс між двома похибками, і жоден параметр не усуває їх обидві. Короткі блоки поводяться як IID bootstrap і занижують ширину інтервалу, тобто створюють зміщення. Довгі блоки зберігають більше залежностей, але залишають менше різних блоків для вибірки. Тому кожна повторна вибірка відтворює великі фрагменти тієї самої історії, а сам інтервал стає нестабільним. Це дисперсія. Опубліковані емпіричні правила масштабують довжину блоку пропорційно N у степені одна третина. Сприймайте їх як початкові орієнтири.

Дешевший діагностичний підхід — перевірити, як дисперсія масштабується з горизонтом. За незалежності дисперсія суми дохідностей за k днів дорівнює k-кратній одноденній дисперсії, а співвідношення цих величин дорівнює 1.

ЗапитКоефіцієнт дисперсії за довжиною блоку: чи масштабується дисперсія SPY як для незалежних спостережень?
Точний SQL-код для кожного числа
WITH daily AS
(
    SELECT
        date,
        toFloat64(close) / nullIf(lagInFrame(toFloat64(close), 1)
            OVER (ORDER BY date ROWS BETWEEN 1 PRECEDING AND CURRENT ROW), 0) - 1 AS ret
    FROM global_markets.stocks_daily_aggs
    WHERE ticker = 'SPY'
      AND date >= '2005-12-01'
      AND date <  '2026-01-01'
),
indexed AS
(
    SELECT
        ret,
        row_number() OVER (ORDER BY date) AS i
    FROM daily
    WHERE date >= '2006-01-01'
      AND ret IS NOT NULL
),
base AS
(
    SELECT varSamp(ret) AS var_1d FROM indexed
),
blocks AS
(
    SELECT
        k,
        intDiv(i, k)  AS blk,
        count()       AS n,
        sum(ret)      AS block_ret
    FROM indexed
    CROSS JOIN (SELECT arrayJoin([2, 3, 5, 10, 21, 42, 63]) AS k) AS ks
    GROUP BY k, blk
    HAVING n = k
)
SELECT
    concat(toString(k), ' sessions')                          AS block_length,
    count()                                                   AS block_count,
    round(varSamp(block_ret) / (k * any(var_1d)), 3)          AS variance_ratio
FROM blocks
CROSS JOIN base
GROUP BY k
ORDER BY k
Run this yourself

На горизонті 2 sessions співвідношення становило 0.844; на горизонті 63 sessions воно становило 0.584. Розрахунок виконано за 78 неперекривними блоками. Значення, близькі до 1, означають, що сума масштабується так, як це відбувалося б для незалежних спостережень на цьому горизонті. Значення, що відхиляються від 1, позначають горизонти, на яких залежність і далі має значення. Саме цей діапазон має охоплювати довжина блоку. Виберіть найкоротший блок, який охоплює його, а потім перевірте, скільки блоків залишилося.

Ресемплінг за групами та кількість спостережень поруч із кожним інтервалом

Ресемплінг у межах груп, волатильнісного режиму або календарного місяця зберігає умови, які зробили запитання важливим. Якщо твердження полягає в тому, що стратегія отримує свій Sharpe у режимах високої волатильності, саме інтервал, побудований лише на днях із високою волатильністю, дає змогу перевірити це твердження. Водночас це має арифметичну ціну. Поділ 250 спостережень на чотири групи залишає приблизно по 60 спостережень у кожній, а bootstrap на 60 спостереженнях дає інтервал приблизно вдвічі ширший, ніж для всієї вибірки.

Тому щоразу вказуйте кількість спостережень поруч із кожним інтервалом. Колонка block_count у наведених вище панелях показує цю практику: значення 5-го перцентиля, розраховане за дев’ятьма вікнами, відрізняється за змістом від значення, розрахованого за двомастами вікнами, навіть якщо обидва округлено до двох десяткових знаків.

Що не може виправити bootstrap

Bootstrap кількісно оцінює лише одне — вибірковий шум у статистиці, розрахованій за наявними даними. Він не дає відповіді, чи можна було взагалі отримати такі дані в реальній торгівлі.

Backtest із зміщенням у бік випередження даних формує ряд дохідності, за яким ніколи не можна було торгувати. Bootstrap такого ряду повертає вузький, упевнений інтервал навколо вигадки. Якщо набір активів сформовано з поточних компонентів індексу, він містить ефект виживших, і кожна повторна вибірка з цього набору успадковує його. Третя проблема — ефект відбору: якщо запустити 200 варіантів і залишити найкращий, його bootstrap-інтервал описує вибірковий шум лише цього варіанта, ігноруючи 199 результатів, які використовувалися для його вибору. Об’єктивні інтервали корисні. Але вони не замінюють дані out-of-sample.

Дані та методика
  • Дохідність розраховано за цінами закриття від одного закриття до іншого на денних барах. Дивіденди не враховано, тому рівень кожного показника дохідності та Sharpe занижено приблизно на величину dividend yield. Розкид, про який ідеться в цьому матеріалі, майже не змінюється.
  • Вікна та блоки не перекриваються, тому кожен виміряний показник використовує окремий фрагмент історії. Вікна, що перекриваються, завищували б видиму кількість спостережень.
  • Для квантилів використано детермінований оцінювач. Тому повторний запуск панелі повертає той самий перцентиль, а не нове наближене значення.
  • У панелі автокореляції використано шість великих компаній, у яких протягом вікна не було дроблення акцій. Також вилучено кожен день із рухом понад 35 відсотків, щоб артефакти коригування цін не впливали на кореляцію.

FAQ

Що показує bootstrap-інтервал довіри для backtest?

Він показує діапазон значень, яких статистика, імовірно, набула б, якби той самий процес повторно вибирали протягом такої самої кількості періодів. Інтервал у 95 відсотків, що містить нуль, означає: вибірка надто коротка, щоб відокремити стратегію від повної відсутності переваги.

Скільки bootstrap-повторних вибірок достатньо?

Для інтервалу в 95 відсотків кілька тисяч повторних вибірок зазвичай забезпечують стабільний результат, а 10 000 є поширеним значенням за замовчуванням і майже не збільшує витрати. Для глибших хвостових квантилів потрібно більше вибірок: 1-й перцентиль із 1 000 спостережень визначається приблизно за десятьма значеннями.

Яку довжину блоку слід використовувати в moving block bootstrap?

Універсально правильної довжини немає. Евристичні правила пов’язують її з обсягом вибірки, піднесеним до степеня одна третя. Практична перевірка — горизонт, на якому дисперсія перестає зростати лінійно; саме його вимірює панель variance ratio вище. Публікуйте використану довжину разом з інтервалом.

Чи можна застосувати bootstrap до максимального drawdown?

Так, але слід враховувати порядок спостережень. Drawdown залежить від послідовності дохідностей, тому повторна вибірка, сформована перемішуванням, показує drawdown перебудованої історії. Block bootstrap зберігає короткі послідовності без змін і є кращим інструментом для статистик, що залежать від траєкторії.

Чи усуває bootstrap overfitting?

Ні. Він вимірює шум вибірки в межах одного ряду дохідностей. Look-ahead bias і ефект відбору, що виникає під час тестування багатьох варіантів, залишаються поза межами його аналізу.


Кожна панель тут містить SQL-запит, за допомогою якого її побудовано. Змініть ticker або довжину вікна й самостійно запустіть запит у терміналі Strasmore.

#backtesting#bootstrap#statistics#confidence intervals#sharpe ratio