Strasmore Research
Дослідження Matt ConnorВід Matt Connor · Оновлено 2026-08-08

Відтворюваний backtest у Python без API key

Як запустити відтворюваний backtest у Python без API key: зафіксована інсталяція, детерміновані дані та чесний аналіз того, що приховує одна equity curve.

Відтворюваний backtest у Python — це такий backtest, який сторонній користувач може повторно запустити на чистій системі й отримати ті самі результати без облікового запису та API key. Більшість навчальних матеріалів не проходять цю перевірку вже на першому рядку: завантаження даних у реальному часі надає наступному читачеві дещо іншу історію цін, ніж ту, яку отримав автор. У цьому огляді зафіксовано одну open-source engine — quantjourney-bt версії 0.12.4. Її вбудований приклад запускається без жодних облікових даних. Потім ми використаємо реальні market data, щоб показати, чого навіть один коректний запуск не може вам повідомити.

Що робить backtest відтворюваним?

У цьому контексті відтворюваність має вузьке значення, яке можна перевірити: друга людина запускає одну команду на чистій машині й отримує ваші результати до останнього десяткового знака. Дві звичайні причини порушують цю умову.

Перша — код. Бібліотека версії 0.x не гарантує сумісність між мінорними релізами. Перейменування, зміна параметра за замовчуванням або переставлений стовпець можуть не зупинити скрипт, але він непомітно почне видавати інший результат.

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

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

Зафіксуйте версію встановлення: pip install quantjourney-bt==0.12.4

quantjourney-bt — це backtester QuantJourney, випущений за ліцензією Apache License 2.0. Для його роботи потрібен Python версії 3.11 або новішої. Версію 0.12.4 опубліковано 21 липня 2026 року. Саме на неї посилається кожна наведена нижче команда, як зазначено в документації за серпень 2026 року.

Працюйте в ізольованому середовищі. python3 -m venv .venv створює його, source .venv/bin/activate активує, а python -m pip install -U pip оновлює в ньому інсталятор. Потім зафіксуйте точну версію: pip install quantjourney-bt==0.12.4.

У документації проєкту наведено форму без фіксації версії — pip install quantjourney-bt. Відповідальність за частину ==0.12.4 лежить на вас. Для версій 0.x це особливо важливо. Запишіть зафіксовану версію там, де її знайде наступний користувач: pip freeze > requirements.txt зберігає всі залежності з остаточно визначеними версіями, зокрема ті, які ви безпосередньо не вказували.

Доступні два додаткові компоненти. pip install "quantjourney-bt[wf]" додає Optuna для прикладів walk-forward-аналізу та оптимізації. pip install "quantjourney-bt[data]" додає резервне джерело даних yfinance, яке використовується для бенчмарків.

Apache-2.0 — дозвільна ліцензія. Ви можете комерційно використовувати та змінювати код. У разі розповсюдження зміненої версії потрібно зберігати файли ліцензії та повідомлення про авторські права. Учасники проєкту також прямо надають патентні права.

Як запустити вбудований приклад SMA без API-ключа

До репозиторію входить скрипт запуску та п’ятдесят готових до виконання прикладів стратегій. Вони поділені на два напрями: на основі ваг і на основі ордерів. Серед них є п’ять walk-forward робочих процесів. ./strategy.sh --list виводить каталог. ./strategy.sh example_weights_01_sma_daily --check імпортує одну стратегію й не звертається до даних. Це найшвидший спосіб перевірити коректність встановлення.

Сам демонстраційний запуск виконується одним рядком: ./strategy.sh example_weights_01_sma_daily --sample-data --output /tmp/qj-sample

Прапорець --sample-data є ключовим. Проєкт описує набір даних за ним так:

Демонстраційний набір даних навмисно зроблено невеликим і відтворюваним. Він підходить для перевірки встановлення, формування звітів і ознайомлення з роботою рушія без створення облікового запису.
Джерело: README quantjourney-bt, версія 0.12.4, переглянуто 6 серпня 2026 року.

Після запуску створюється каталог, а не повідомлення про результат у консолі: summary.txt і summary.json, файл metrics.csv, файл equity_curve.csv поруч із equity_curve.png, файл dashboard.html, папка plots/ і файл run_metadata.json із записом параметрів запуску. Саме цей останній файл найчастіше пропускають. Водночас він дає змогу перевірити результат через рік.

Оцінюйте отримані метрики коректно. Вбудований набір даних невеликий і має ілюстративний характер. Тому коефіцієнт Sharpe та максимальна просадка, надруковані у summary.txt, описують лише демонстраційний файл. Вони не є доказом якості стратегії. Сприйняти їх як результат — перша можлива помилка.

Запуск підтверджує важливі речі: встановлення працює, а повний цикл рушія — від сигналу до цільових ваг і відновленої вартості портфеля — створює всі артефакти на вашому комп’ютері без жодних облікових даних. Для backtests на реальній історії проєкт має окремий шлях із автентифікацією до власного сервісу даних. Цей шлях задокументовано. На цьому walkthrough завершується — на етапі, якому нічого не потрібно від сторонніх сервісів.

Що не показує одна внутрішньовибіркова крива капіталу

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

Наведена нижче панель бере ту саму ідею, яку використовує стратегія з прикладу: перетин 20-сесійної ковзної середньої з 50-сесійною. Вона застосовує це правило до SPY і показує результати окремо за кожним календарним роком із 2017 до 2025 року. Позиція в кожній сесії визначається ціною закриття попередньої сесії. Отже, правило ніколи не торгує за даними, яких на той момент ще не було.

ЗапитПеретин ковзних середніх 20/50 для SPY за роками проти стратегії утримання
Точний SQL-код для кожного числа
WITH daily AS
(
    SELECT
        toDate(toTimeZone(window_start, 'America/New_York')) AS d,
        toFloat64(argMax(close, window_start))               AS px
    FROM global_markets.delayed_stocks_minute_aggs
    WHERE ticker = 'SPY'
      AND window_start >= '2016-01-01 00:00:00'
      AND window_start <  '2026-01-01 05:00:00'
      AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
           + toMinute(toTimeZone(window_start, 'America/New_York'))) >= 570
      AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
           + toMinute(toTimeZone(window_start, 'America/New_York'))) < 960
    GROUP BY d
),
averaged AS
(
    SELECT
        d,
        px,
        avg(px) OVER (ORDER BY d ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS fast_ma,
        avg(px) OVER (ORDER BY d ROWS BETWEEN 49 PRECEDING AND CURRENT ROW) AS slow_ma,
        row_number() OVER (ORDER BY d)                                      AS session_no
    FROM daily
),
positioned AS
(
    SELECT
        d,
        px,
        if(session_no >= 50 AND fast_ma > slow_ma, 1, 0) AS long_today,
        lagInFrame(if(session_no >= 50 AND fast_ma > slow_ma, 1, 0), 1)
            OVER (ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS long_prior,
        lagInFrame(px, 1)
            OVER (ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS px_prior
    FROM averaged
)
SELECT
    toYear(d)                                                                   AS year,
    round((exp(sum(log(if(long_prior = 1, px / px_prior, 1.0)))) - 1) * 100, 1) AS rule_pct,
    round((exp(sum(log(px / px_prior))) - 1) * 100, 1)                          AS hold_pct,
    countIf(long_today != long_prior)                                           AS crossover_count
FROM positioned
WHERE px_prior > 0
  AND toYear(d) >= 2017
GROUP BY year
ORDER BY year
Run this yourself

Одне незмінне правило, виміряне в 9 окремих періодах. Спочатку перегляньте два стовпці з відсотками. У 2017 правило завершило рік із результатом 16% проти 19.4% для стратегії утримання SPY протягом того самого періоду. У 2025 ці самі два стовпці показують 10.4% і 16.4%. Код в обох рядках ідентичний. Змінилося лише вікно.

Стовпець із перетинами показує, наскільки обмеженою є базова вибірка. 4 змін позиції протягом 2025 означає, що річна крива капіталу ґрунтується лише на кількох рішеннях. Це дуже мала вибірка, щоб вважати результат переконливим.

Чи працює це саме правило так само для інших паперів?

Зміна часового вікна — один зі способів проаналізувати окрему криву. Інший — змінити інвестиційний всесвіт. У таблиці нижче параметри зафіксовано, а те саме правило застосовано до п’яти ліквідних паперів за п’ять календарних років — з 2021 до 2025 року.

ЗапитТа сама стратегія 20/50 для п’яти ліквідних акцій, 2021–2025
Точний SQL-код для кожного числа
WITH daily AS
(
    SELECT
        ticker,
        toDate(toTimeZone(window_start, 'America/New_York')) AS d,
        toFloat64(argMax(close, window_start))               AS px
    FROM global_markets.delayed_stocks_minute_aggs
    WHERE ticker IN ('SPY', 'QQQ', 'AAPL', 'MSFT', 'KO')
      AND window_start >= '2020-07-01 00:00:00'
      AND window_start <  '2026-01-01 05:00:00'
      AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
           + toMinute(toTimeZone(window_start, 'America/New_York'))) >= 570
      AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
           + toMinute(toTimeZone(window_start, 'America/New_York'))) < 960
    GROUP BY ticker, d
),
averaged AS
(
    SELECT
        ticker,
        d,
        px,
        avg(px) OVER (PARTITION BY ticker ORDER BY d ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS fast_ma,
        avg(px) OVER (PARTITION BY ticker ORDER BY d ROWS BETWEEN 49 PRECEDING AND CURRENT ROW) AS slow_ma,
        row_number() OVER (PARTITION BY ticker ORDER BY d)                                      AS session_no
    FROM daily
),
positioned AS
(
    SELECT
        ticker,
        d,
        px,
        lagInFrame(if(session_no >= 50 AND fast_ma > slow_ma, 1, 0), 1)
            OVER (PARTITION BY ticker ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS long_prior,
        lagInFrame(px, 1)
            OVER (PARTITION BY ticker ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS px_prior
    FROM averaged
)
SELECT
    ticker                                                                      AS symbol,
    round((exp(sum(log(if(long_prior = 1, px / px_prior, 1.0)))) - 1) * 100, 1) AS rule_pct,
    round((exp(sum(log(px / px_prior))) - 1) * 100, 1)                          AS hold_pct,
    round(avg(long_prior) * 100, 0)                                             AS days_long_pct
FROM positioned
WHERE px_prior > 0
  AND d >= toDate('2021-01-01')
GROUP BY symbol
ORDER BY rule_pct DESC
Run this yourself

QQQ розташований у верхній частині таблиці на рівні 40.2%, а нижній рядок, KO, показує результат 3.8%. У стовпці days_long_pct зазначено, яку частину періоду кожна версія фактично утримувала позицію, для верхнього рядка це 67%. Один набір параметрів, п’ять інвестиційних всесвітів і такий широкий діапазон результатів, що вибір переможця постфактум нічого не говорить про ще не проведений запуск.

Це не рекомендація торгувати за сигналами перетину. Перетин слугує мірилом для backtest, а об’єктом вимірювання є саме backtest.

Де в backtest вагових коефіцієнтів виникає look-ahead bias

Рушій на основі ваг перетворює сигнал на цільові ваги, моделює виконання угод відповідно до цих ваг, а потім відновлює вартість портфеля з отриманих позицій. Помилка прихована в місці поєднання сигналу та ваги. Якщо сьогоднішня вага визначається ціною закриття сьогоднішньої сесії, а потім приносить дохідність за сьогодні, backtest використовує інформацію, якої не існувало на момент виставлення ордера. Це look-ahead bias, і система не виводить жодної помилки. Просто всі результати виглядають кращими.

На панелі нижче показано обидві версії одного правила на однаковій історії SPY.

ЗапитТа сама стратегія: сигнал попередньої сесії проти сигналу тієї самої сесії, SPY за роками
Точний SQL-код для кожного числа
WITH daily AS
(
    SELECT
        toDate(toTimeZone(window_start, 'America/New_York')) AS d,
        toFloat64(argMax(close, window_start))               AS px
    FROM global_markets.delayed_stocks_minute_aggs
    WHERE ticker = 'SPY'
      AND window_start >= '2016-01-01 00:00:00'
      AND window_start <  '2026-01-01 05:00:00'
      AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
           + toMinute(toTimeZone(window_start, 'America/New_York'))) >= 570
      AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
           + toMinute(toTimeZone(window_start, 'America/New_York'))) < 960
    GROUP BY d
),
averaged AS
(
    SELECT
        d,
        px,
        avg(px) OVER (ORDER BY d ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS fast_ma,
        avg(px) OVER (ORDER BY d ROWS BETWEEN 49 PRECEDING AND CURRENT ROW) AS slow_ma,
        row_number() OVER (ORDER BY d)                                      AS session_no
    FROM daily
),
positioned AS
(
    SELECT
        d,
        px,
        if(session_no >= 50 AND fast_ma > slow_ma, 1, 0) AS long_today,
        lagInFrame(if(session_no >= 50 AND fast_ma > slow_ma, 1, 0), 1)
            OVER (ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS long_prior,
        lagInFrame(px, 1)
            OVER (ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS px_prior
    FROM averaged
)
SELECT
    toYear(d)                                                                   AS year,
    round((exp(sum(log(if(long_prior = 1, px / px_prior, 1.0)))) - 1) * 100, 1) AS next_bar_pct,
    round((exp(sum(log(if(long_today = 1, px / px_prior, 1.0)))) - 1) * 100, 1) AS same_bar_pct,
    round(abs(exp(sum(log(if(long_today = 1, px / px_prior, 1.0))))
              - exp(sum(log(if(long_prior = 1, px / px_prior, 1.0))))) * 100, 1) AS gap_pp
FROM positioned
WHERE px_prior > 0
  AND toYear(d) >= 2017
GROUP BY year
ORDER BY year
Run this yourself

У 2017 версія з попередньою сесією показала 16%, тоді як версія з тією самою сесією показала 17.1%. Різниця становила 1.1 відсоткового пункту. У 2025 розрив між двома показниками становив 1.6 відсоткового пункту. Лише одну з цих колонок може сформувати система, яка заздалегідь не знала, де завершиться сесія. Різниця між ними є суто результатом обліку. За нею не стоять ані інвестиційна ідея, ані майстерність, ані угода.

Цей рушій прямо описує власний підхід до питання таймінгу. Це важливіше за будь-які запевнення:

Для виконання угод на відкритті slippage, чутливий до діапазону, використовує лише попередній завершений бар, а пропускна здатність за обсягом прогнозується на основі лагованих спостережень. Рушій не використовує пізніші high, low, close або повний денний обсяг цієї сесії.
Джерело: README quantjourney-bt, версія 0.12.4, переглянуто 6 серпня 2026 року.

Задокументоване припущення можна перевірити за вихідним кодом, який ви вже встановили. Недокументоване припущення є лише здогадкою.

Навіщо потрібен додатковий walk-forward-приклад

Приклади walk-forward WF01–WF05 постачаються разом із додатковим компонентом [wf] і залежністю Optuna. Walk-forward підбирає параметри на одному відрізку історії, оцінює їх на наступному відрізку, потім зсуває цю пару вперед і повторює процедуру. У rolling- та expanding-варіантах відмінність полягає в тому, чи видаляються найстаріші дані з вікна підбору в міру його просування. Ще один приклад додає purge та embargo на кожній межі, вилучаючи спостереження, найближчі до стику. Це не дає даним із відрізка підбору потрапити у відрізок, на якому проводиться оцінка.

Усе це не перетворює слабку ідею на робочу стратегію. Такий підхід замінює одне число розподілом чисел, який можна аналізувати й оскаржувати. Саме в цьому полягає його основна перевага. Наступний крок — ще не торгівля реальними коштами: паперова торгівля перед використанням реальних коштів дає змогу оцінити те, чого структурно не бачить backtest. Передусім ідеться про те, чи виконується ордер за ціною, близькою до тієї, яку припускав симулятор. А аварійні вимикачі для торговельних ботів визначають, як поводитиметься код у день, коли щось піде не так. Статистичні основи всіх цих підходів детальніше розглянуто в наших нотатках до книги з кількісної торгівлі з відкритим кодом.

Часті запитання

Чи можна протестувати стратегію на історичних даних без API-ключа?

Так. quantjourney-bt постачається зі зразком даних, доступним через прапорець --sample-data, а приклади стратегій запускаються на ньому без облікового запису та облікових даних. Набір даних невеликий і має ілюстративний характер, тому сприймайте такий запуск як перевірку встановлення та конвеєра, а не як доказ ефективності стратегії.

Навіщо фіксувати версію Python-пакета для backtesting?

Пакет із версією 0.x не гарантує сумісності між мінорними релізами, а зміна значення за замовчуванням або перейменування метрики може залишитися непомітною. Фіксація версії за допомогою pip install quantjourney-bt==0.12.4 і запис середовища у файл requirements означають, що результат, отриманий сьогодні, можна буде відтворити наступного року на тому самому рушії.

За якою ліцензією випускається quantjourney-bt?

Apache License 2.0. Вона дозволяє комерційне використання та модифікацію, вимагає зберігати файли ліцензії й повідомлень під час будь-якого розповсюдження та містить явне патентне надання від контриб’юторів. Версію 0.12.4 опубліковано 21 липня 2026 року; для її роботи потрібен Python 3.11 або новіший.

Чи означає сильний результат backtest, що стратегія працює?

Ні. Backtest — це один вимір за один період на одному наборі активів. Панелі вище показують, що одне й те саме незмінне правило дає дуже різні річні результати для одного тикера та дуже різні результати для п’яти назв. Саме такий розрив мають виявляти walk-forward validation і out-of-sample testing.

Як розраховано наведені вище панелі

Денні ціни закриття — це останній print регулярної сесії за кожну дату, взятий за нью-йоркським часом у проміжку з 9:30 до 16:00. Це дає змогу коректно враховувати дні з достроковим закриттям без жорсткого задання тривалості сесії. Швидка середня охоплює 20 сесій, а повільна — 50; обидві є простими ковзними середніми. Перші 49 сесій кожного ряду є періодом розігріву, протягом якого позиція не відкривається. Річні показники обчислюються шляхом компаундингу руху від закриття до закриття кожної сесії, коли правило утримувало довгу позицію. Для порівняння в колонці hold компаундується рух кожної сесії відповідного року. П’ять назв у поперечному зрізі відібрано за безперервною історією без split протягом вікна, тому ряд цін закриття не потребує коригування. Вікна зафіксовані в минулому, тому ці панелі повертають однакові числа під час кожної повторної генерації.


Під кожною панеллю наведено точний SQL-код, тому числа на цій сторінці можна повторно розрахувати так само, як і під час інсталяції з зафіксованою версією. Щоб виміряти правило на власному часовому вікні ще до написання коду для backtest, сформулюйте запит простою англійською мовою в терміналі Strasmore.

#backtesting#python#open source#reproducibility#quantjourney