Налог на фреймворк: когда ваша библиотека для бэктестов медленнее наивного цикла на pandas
Часть серии "Бэктесты без иллюзий".
В основании большинства алготрейдинговых проектов лежит удобное допущение: зрелая, популярная библиотека для бэктестов — быстрая. За ней годы разработки, настоящий событийный цикл, модель брокера, схема комиссий. Она наверняка обгонит наспех написанный вами цикл на pandas. Поэтому вы берете ее, подключаете свою стратегию и запускаете перебор параметров — несколько тысяч конфигураций, задача на ночь. Утром вы возвращаетесь, а она все еще работает.
Мы прогнали восемь движков для бэктестов через один и тот же перебор параметров и обнаружили то, что должно изменить ваш подход к выбору инструмента для поиска. Два самых популярных опенсорсных событийно-ориентированных фреймворка — backtrader и bt — выполнили перебор медленнее, чем наивный цикл на pandas, написанный нами как одноразовый baseline. Не немного медленнее. backtrader занял примерно в 2.5 раза больше времени, чем baseline на pandas; bt — примерно в 4.7 раза. При этом векторизованный/компилируемый движок на той же работе выполнил ее примерно в 13 000 раз быстрее, чем bt.
Это и есть налог на фреймворк. Популярные движки для бэктестов строятся под один честный прогон — одна стратегия, один набор данных, аккуратные исполнения ордеров, брокер, который ведет себя как брокер. Это именно то, что нужно для финальной валидации или проверки на соответствие живой торговле. И это ровно неподходящий инструмент для того, на что реально уходит время в алгоритмическом исследовании: прогон одной и той же стратегии десять тысяч раз с чуть разными настройками. Эта статья измеряет налог, объясняет механизм и дает правило принятия решения о том, когда "настоящий" фреймворк для бэктестов — неверный выбор.
Каждая цифра здесь получена из одного воспроизводимого стенда (benchmarks/bench_oss_engines.py, коммит 250dbb5) на идентичной нагрузке с закрепленным паритетом. Там, где мы не запускали движок сами, мы прямо об этом говорим и выносим это в отдельный раздел честности, а не выдумываем цифру.
Один прогон против десяти тысяч

Определяющий факт поиска по параметрам в том, что движок запускается тысячи раз, а анализ происходит один раз. Какую бы фиксированную цену вы ни платили за настройку бэктеста — построение событийного цикла, создание экземпляра брокера, выделение объекта на каждый бар — вы платите ее на каждой отдельной комбинации. Издержка, незаметная в одном прогоне ("кому есть дело до 6 секунд?"), становится всем счетом на переборе ("6 секунд × 10 000 = 16.6 часа").
Движки для бэктестов делятся на три парадигмы, и парадигма определяет производительность на переборе:
- Событийно-ориентированный (event-driven) — движок идет бар за баром, генерирует события, вызывает ваш колбэк
next()/onBars(), маршрутизирует ордера через объект брокера. Это архитектура backtrader, backtesting.py, PyAlgoTrade, zipline и nautilus_trader. Она отражает то, как реально работает живая торговля, и именно поэтому ей доверяют за реалистичность — и именно поэтому она медленная: накладные расходы Python на каждый бар оплачиваются 150 000 раз на каждую комбинацию. - Весовая/ребалансировочная (weights/rebalance) — здесь находится
bt. Вы передаете ему матрицу целевых весов, и он ребалансирует в указанные вами даты. Нет колбэка на каждый бар, но есть граф объектов на каждое событие (дерево алгоритмов, журнал транзакций), вычисляемый в Python. - Векторизованная / компилируемая (vectorized / compiled) — вся стратегия выражена как операции над массивами (vectorbt), JIT-компилируемое ядро (numba) или нативный код (Rust, GPU-ядро на MLX). Python на каждый бар отсутствует вовсе. Цикл, если он есть, выполняется на скорости машинного кода.
Остальная часть статьи — эмпирическое следствие этой таксономии. Мы построили одну нагрузку, добились того, чтобы каждый движок доказуемо выполнял одну и ту же работу, и замерили время.
Нагрузка: одна стратегия, восемьдесят настроек, одинаковая для всех
Бенчмарк честен только тогда, когда каждый движок выполняет одну и ту же работу. Наш намеренно прост — это нагрузка, в которой между движками отличается только сам движок.
- Данные. Единый синтетический ряд цен закрытия по геометрическому броуновскому движению:
150,000баров,seed=42, волатильность на барsigma=0.0008,x0=30000. Детерминированный, поэтому любой может воспроизвести его бит в бит. По построению only-close — OHLC на каждом плече установлен равным цене закрытия, потому что стратегия основана на пересечении по закрытию. - Стратегия. Пересечение скользящих средних Hull (HMA): HMA длины
Lпротив более быстрого варианта HMA от трети длины (HMAпротивHMA3). Позиция всегда открыта, переворот в лонг/шорт на каждом пересечении. Это настоящий, нетривиальный индикатор — две вложенные взвешенные скользящие средние плюс сглаживание с окном по квадратному корню — а не игрушечная SMA, поэтому работа на каждый бар репрезентативна. - Перебор.
80длин HMA в диапазоне6..200. Это те самые "десять тысяч прогонов", уменьшенные до размера, который можно измерить напрямую: 80 независимых комбинаций, каждая — полный бэктест на 150 тысячах баров. - Издержки. Комиссия за круговую сделку
0.09%, разделенная по сторонам для движков, моделирующих комиссии по каждой стороне сделки. Исполнение в том же баре поclose[i]— сигнал на бареiисполняется по цене закрытия этого же бара, конвенция, которую использует наш продакшн-движок.
Таймер на комбинацию охватывает ровно две вещи: предварительный расчет HMA средствами numpy и сам прогон движка. Настройка, которая происходит по-настоящему один раз (загрузка данных, построение объектов баров), находится вне таймера. Делается один прогон для прогрева, затем best-of-N повторов, и — поскольку событийно-ориентированные движки достаточно медленные, чтобы 80 полных комбинаций занимали от многих минут до более часа — мы замеряем равномерную выборку из сетки и линейно экстраполируем. Комбинации независимы, поэтому линейная экстраполяция точна в математическом ожидании; та же конвенция применена к baseline на pandas, так что ни один движок не получает от этого преимущества.
Паритет: доказательство того, что все выполняют одну и ту же работу
Вот ловушка, в которую попадает наивное сравнение движков: "быстрый" движок может просто делать меньше. Если backtrader фиксирует 2700 сделок, а ваш векторизованный движок — 40, то векторизованный движок не быстрее — он неверен, и сравнение бессмысленно.
Поэтому мы закрепляем сравнение проверкой паритета по числу сделок. При L=104 эталон на numpy дает ровно 2707 закрытых сделок. Каждый движок обязан воспроизвести это с допуском ±1, иначе прогон прерывается с утверждением work-parity FAILED. Допуск существует только из-за того, что движки расходятся в конвенциях учета — принудительно ли закрывается и засчитывается финальная открытая позиция, считается ли первоначальный вход "сделкой" — а не из-за расхождений в самих сделках:
| Движок | Заявлено сделок @ L=104 | Конвенция |
|---|---|---|
| эталон numpy | 2707 | закрытые круговые сделки |
| backtesting.py | 2708 | +1: финальная позиция принудительно закрыта в конце |
| backtrader | 2707 | финальная открытая позиция не учитывается |
| bt | 2708 | +1: начальный вход засчитан как транзакция |
| PyAlgoTrade | 2708 | +1: начальный вход засчитан как исполнение |
Каждый движок попадает в 2707 ± 1. Какими бы ни оказались различия в скорости, они не артефакт того, что какой-то движок тихо пропускает работу. Именно эта дисциплина позволяет нам поместить событийно-ориентированный фреймворк и GPU-ядро в одну таблицу и говорить об этом всерьез.
Результаты
Вот полная таблица, отсортированная от самого быстрого к самому медленному. combos/s — это пропускная способность (комбинаций в секунду); последний столбец — сколько времени занимает полный перебор из 80 комбинаций. Строка baseline — это M0, наивный движок на pandas, цикл for по барам со скалярным учетом позиции, то, что можно написать за полдня и выбросить. Все, что медленнее этого baseline, выделено жирным.
| Движок | combos/s | Парадигма | Полный перебор из 80 комбинаций |
|---|---|---|---|
| MLX GPU kernel | 779 | векторизованная (GPU Apple) | 0.10 с |
| native Rust | ~350 | компилируемая | 0.23 с |
| mp + numba | 246 | компилируемый JIT + мультипроцессинг | 0.33 с |
| vectorbt | 56.9 | векторизованная (numpy/numba) | 1.4 с |
| numba (одно ядро) | 39.7 | компилируемый JIT | 2.0 с |
| backtesting.py | 1.42 | событийно-ориентированная | 56 с |
| PyAlgoTrade | 0.51 | событийно-ориентированная | 2.6 мин |
| M0 — наивный pandas + цикл | 0.28 | скалярный baseline | 4.8 мин |
| backtrader | 0.11 | событийно-ориентированная | 12.7 мин |
| bt | 0.06 | веса / ребалансировка | 22.5 мин |
Читайте таблицу сверху вниз, и парадигма сортируется сама: верхние пять все векторизованные или компилируемые, нижние пять — все событийно-ориентированные или основанные на графе объектов, причем наивный цикл на pandas оказывается выше двух зрелых, популярных фреймворков. Разброс от верха до низа — четыре порядка величины. На той же самой нагрузке в 2707 сделок ядро MLX завершает перебор за десятую долю секунды; bt требуется двадцать две с половиной минуты. Это разница примерно в 13 000 раз.
Скандал в середине таблицы

Цифры, бросающиеся в глаза, находятся на краях, но поучительный результат — в середине: backtrader (0.11 combos/s) и bt (0.06 combos/s) оба медленнее наивного baseline на pandas (0.28 combos/s).
Это стоит осознать в полной мере. M0 — не хитроумный движок. Это цикл for на Python, который индексирует DataFrame, отслеживает позицию и денежные средства в обычных скалярах и дописывает сделки в список — намеренно неоптимизированный "контроль", включенный нами, чтобы иметь что-то заведомо плохое для сравнения. Доступ к строкам pandas по одной печально известен своей медлительностью, и мы этим воспользовались. И тем не менее две из самых рекомендуемых библиотек для бэктестов в экосистеме проигрывают ему: backtrader — в 2.5 раза, bt — в 4.7 раза.
Нюанс, который сохраняет честность картины: не каждый событийно-ориентированный движок медленнее pandas. backtesting.py (1.42 combos/s) обгоняет baseline в 5 раз, потому что это компактный событийный цикл на базе numpy, сводящий создание объектов на каждый бар к минимуму. PyAlgoTrade (0.51) тоже немного опережает baseline. То есть "событийно-ориентированный" не является автоматическим смертным приговором — но чем тяжелее машинерия на каждый бар, тем хуже дело, а у backtrader и bt здесь самая тяжелая машинерия. Парадигма задает потолок; реализация решает, где под этим потолком вы окажетесь.
Дело не в том, что это плохие библиотеки. Модель брокера backtrader и дизайн дерева алгоритмов у bt существуют, чтобы дать вам корректность и выразительность — реалистичную обработку ордеров, ребалансировку портфеля, анализаторы. У этих возможностей есть цена в рантайме, и эта цена незаметна при одном прогоне. На переборе она составляет всю историю.
Почему событийно-ориентированные движки платят налог

Механизм не является загадкой. Событийно-ориентированный бэктест на каждом баре делает что-то вроде этого:
- Продвинуть часы, отрезать следующий бар от ленты данных и материализовать его как объект (
Bar,Line, словарь). - Вызвать колбэк в пользовательском коде (
next(),onBars()) — это вызов функции Python со своим собственным фреймом. - Внутри колбэка запросить состояние брокера/позиции, опять же через вызовы методов и поиск атрибутов.
- Если ордер создан, провести его через брокера: провалидировать, проверить маржу/денежные средства, запланировать исполнение, изменить объект портфеля, дописать в журнал транзакций.
- Обновить анализаторы, наблюдатели и любой учет, который ведет фреймворк.
Теперь умножьте на 150 000 баров, а затем на 80 комбинаций: двенадцать миллионов итераций по барам на весь перебор, и каждая — это пригоршня выделений объектов и динамических диспетчеризаций на уровне Python. Накладные расходы Python на операцию — от десятков до сотен наносекунд на поиск атрибута или небольшое выделение памяти — тривиальны один раз и разорительны двенадцать миллионов раз. Случай bt — вариант той же болезни: хотя он ребалансирует только в даты сделок, а не на каждом баре, каждая ребалансировка вычисляет дерево объектов-алгоритмов и затрагивает журнал портфеля на базе pandas, а таких на комбинацию приходится 2707, умноженное на 80.
Наивный цикл на pandas обгоняет backtrader и bt по прямолинейной причине: он делает меньше на каждый бар. Он пропускает брокера, объекты событий, стек анализаторов, конечный автомат маршрутизации ордеров. Он платит уродливый налог pandas на построчный доступ, но этот единственный уродливый налог все равно дешевле, чем аккуратный, полнофункциональный налог фреймворка на объект-на-событие. Когда вы сводите бэктест к "позиция × следующая доходность − комиссии", большая часть того, что фреймворк делает на каждом баре, — это накладные расходы, которые вы не используете во время поиска.
И вот в чем ловушка: эти накладные расходы — та самая причина, по которой вы выбрали фреймворк. Вам был нужен реалистичный брокер. Вам были нужны анализаторы. На финальной валидации вам нужно все это. А во время поиска по 10 000 комбинациям, где на выходе вы читаете всего один скалярный показатель цели, вы платите за лимузин, чтобы он наматывал круги.
Другой конец таблицы: векторизованные и компилируемые
Верх таблицы — это то, что происходит, когда вы полностью убираете Python на каждый бар.
- vectorbt (56.9 combos/s) выражает всю стратегию как операции над массивами numpy/numba. Цикла по барам на Python нет вовсе — сигнал, позиция, PnL — все на уровне массивов. Он выполняет перебор за 1.4 секунды против 22.5 минуты у
bt: примерно в 950 раз быстрее на идентичной работе. (Мы подробнее разбираем устройство vectorbt в обзоре vectorbt и в более широком сравнении pandas против polars.) - numba (39.7) — это тот же цикл на pandas, не изменившийся по форме, но JIT-компилированный в нативный код. Тот же алгоритм, что и у M0, но
@njitпревращает 0.28 combos/s в 39.7 — ускорение примерно в 140 раз от одного декоратора, потому что накладные расходы интерпретатора, доминировавшие в скалярном цикле, просто испаряются. - mp + numba (246) запускает компилируемое ядро на нескольких ядрах CPU. Комбинации тривиально параллелизуемы — каждая независима — поэтому мультипроцессинг масштабируется почти линейно поверх JIT.
- native Rust (~350) убирает последние остатки Python-склейки: весь перебор нативный.
- MLX GPU (779) отображает перебор на GPU-ядро Apple Silicon. 80 комбинаций становятся 80 параллельными дорожками арифметики; перебор завершается прежде, чем вы отпустите клавишу enter.
Стоит точно назвать две вещи. Во-первых, numba доказывает, что парадигма важнее языка. M0 и numba выполняют один и тот же алгоритм — разница исключительно в том, что один интерпретируется на Python на каждый бар, а другой компилируется. Это весь налог на фреймворк в контролируемом A/B-сравнении: ~140 раз за удаление интерпретатора из внутреннего цикла. Во-вторых, скачок от numba (39.7) к mp+numba (246) и к MLX (779) уже вообще не про движок — это про оркестрацию и железо. Как только налог на бар исчез, скорость становится вопросом того, сколько комбинаций вы запускаете параллельно и на каком кремнии. Мы разбираем всю эту прогрессию в статье лесенка скорости движков для бэктестов, а о том, почему последнюю милю определяют издержки на процессы и сериализацию — в статье про IPC-налог.
Что мы не измеряли (и почему мы вам об этом говорим)
Достоверность бенчмарка живет в том, что он отказывается подделывать. Мы прогнали восемь движков от начала до конца под паритетом. По нескольким известным фреймворкам мы не получили цифру, и мы предпочитаем честно перечислить их, а не экстраполировать значение, которое не измеряли:
- zipline / zipline-reloaded — событийно-ориентированный, происхождение от Quantopian. Тяжелая настройка (полный торговый календарь и пакет данных), из-за чего честное сравнение времени на комбинацию становится непростым. Архитектурно он находится в одном лагере с backtrader, в событийно-ориентированном; мы бы ожидали его рядом с тем концом таблицы, но мы это не доказали.
- nautilus_trader — событийно-ориентированный, с ядром на Rust/Cython, явно спроектированный для соответствия живой торговле. Его ядро компилируется, поэтому это наиболее вероятный событийно-ориентированный движок, который не платит полный налог Python — по-настоящему интересное измерение, которое мы пока не провели.
- QuantConnect Lean — построен на C#, совершенно другой рантайм; напрямую не сравним в стенде на Python.
- Jesse — событийно-ориентированный, сфокусирован на крипте; мы разобрали его устройство в отдельной заметке, но не бенчмаркали его здесь.
- QSTrader — событийно-ориентированный, ориентирован на портфель; те же оговорки по парадигме.
- fastquant — мы пробовали; установка/API оказались сломаны в нашем окружении, поэтому цифры нет. Мы не будем ее угадывать.
Две честные оговорки о цифрах, которые мы действительно приводим. Показатели vectorbt, numba, mp+numba, native-Rust и MLX получены из нашей собственной лесенки движков на идентичной нагрузке, а не из опенсорсного стенда, который дал четыре событийно-ориентированные строки, — это та же самая нагрузка, но другая измерительная установка, а цифра native-Rust — приблизительная ~350, а не точное измерение. И абсолютное значение combos/s зависит от железа; что переносится между установками — это порядок и соотношения, которые достаточно велики (13 000 раз от верха до низа, 2.5-4.7 раза для инверсии pandas-против-фреймворка), чтобы никакое разумное различие в железе их не перевернуло.
В защиту событийно-ориентированных движков

Было бы легко прочитать это как "событийно-ориентированные движки для бэктестов — это плохо". Это неверный урок, и несправедливый.
Событийно-ориентированные движки построены для другой задачи, и они хороши в ней. Брокер на каждый бар, жизненный цикл ордера, логика исполнения, анализаторы — эта машинерия существует, чтобы бэктест как можно точнее напоминал живую торговлю. Когда ваша цель — единственная, заслуживающая доверия генеральная репетиция стратегии, которую вы вот-вот развернете, вы хотите, чтобы движок потел над каждым исполнением, моделировал частичные исполнения, уважал маржу и отказывался позволить вам торговать по ценам, которых вы не могли бы получить. Эта точность и есть продукт. Ее цена в рантайме — плата за реализм, и для одного прогона эта цена пренебрежимо мала.
Провал не в движке; он в использовании его не на той фазе. У исследования есть две различные фазы с противоположными требованиями:
- Поиск хочет пропускной способности. Вы исследуете ландшафт, большая часть которого — мусор, и вам нужно оценить тысячи точек, чтобы найти те немногие, что заслуживают второго взгляда. Точность на точку почти не важна — вы ранжируете, а не разворачиваете. Здесь налог на фреймворк — чистые потери.
- Валидация хочет точности. У вас есть горстка кандидатов, и вам нужно знать максимально точно, выживают ли они при реалистичном исполнении, комиссиях, проскальзывании и ловушках заглядывания вперед, которые надувают бумажную доходность. Здесь событийно-ориентированный движок оправдывает свою цену.
Ошибка, которую наказывает налог на фреймворк, — это прогон вашего поиска на вашем валидационном движке — оплата тарифов лимузина за исследование ландшафта, 99% которого вы все равно выбросите.
Правило принятия решения
Практический вывод сжимается до одного решения:
Ищите на векторизованном/компилируемом движке. Валидируйте выживших на событийно-ориентированном.
Конкретно:
- Постройте или возьмите готовое быстрое ядро для перебора. vectorbt, если хотите готовое решение; компилируемый numba-цикл, если ваша стратегия не векторизуется чисто (один декоратор
@njitдал здесь ~140-кратное ускорение). Прогоните через него все пространство параметров. - Никогда не запускайте большой перебор на backtrader,
bt, zipline или любом тяжелом событийно-ориентированном фреймворке. Если перебор на одном из них — ваше узкое место, решение не в более мощной машине — а в том, что это неправильный движок. Даже наивный цикл на pandas обогнал бы два из них. - Передвиньте короткий список на событийно-ориентированный движок для проверки точности. Возьмите горстку выживших и перезапустите их на реалистичном движке, где модель брокера и логика исполнения способны вскрыть проблемы, которые быстрое ядро абстрагировало прочь.
- Обеспечьте паритет между ними двумя. Быстрый движок и точный движок должны сходиться по числу сделок и PnL на фиксированной конфигурации (наша проверка ±1 сделка при L=104), иначе поиск и валидация измеряют разные стратегии, и весь пайплайн — ложь.
Это та же двухскоростная архитектура, которая проявляется всякий раз, когда у поиска и валидации противоположные профили издержек, и именно поэтому наш собственный стек держит быстрый векторизованный/компилируемый путь для поиска параметров и резервирует тяжелую машинерию для финальной оценки целевой функции и проверок на плато.
Выводы
- Популярный ≠ быстрый для переборов. На одной идентичной нагрузке с закрепленным паритетом (150 тысяч баров, 80 комбинаций HMA-пересечения, 2707 сделок) backtrader (0.11 combos/s) и
bt(0.06) оба оказались медленнее наивного цикла на pandas (0.28). Зрелый, популярный фреймворк не является автоматически быстрым выбором. - Налог на фреймворк взимается на каждый бар, и перебор его умножает. Двенадцать миллионов итераций по барам на весь перебор, каждая несет объект события, колбэк и обращение к брокеру туда-обратно. Издержка, незаметная в одном прогоне, становится всем счетом на десяти тысячах.
- Парадигма задает потолок. Векторизованные/компилируемые движки (vectorbt 56.9, numba 39.7, mp+numba 246, native Rust ~350, MLX 779) обгоняют событийно-ориентированные движки на два-четыре порядка величины — до ~13 000 раз от верха до низа. Тот же самый алгоритм, лишь JIT-компилированный, стал в 140 раз быстрее.
- Не все событийно-ориентированные движки равны. backtesting.py (1.42) и PyAlgoTrade (0.51) все же обгоняют наивный baseline; налог масштабируется с тем, насколько тяжела машинерия на каждый бар. Реализация решает, где под потолком вы окажетесь.
- Два движка, две фазы. Ищите на векторизованном/компилируемом ядре; валидируйте выживших на реалистичном событийно-ориентированном движке. Обеспечьте между ними паритет по числу сделок/PnL, чтобы оба измеряли одну и ту же стратегию.
- Будьте честны насчет того, что вы измерили. Мы бенчмаркали восемь движков под паритетом и перечислили те, которые не бенчмаркали (zipline, nautilus_trader, Lean, Jesse, QSTrader и не заработавшую установку fastquant), вместо того чтобы выдумывать для них цифры.
Неприятный итог: если перебор параметров — ваше узкое место, проблема, вероятно, не в вашей машине и не в вашей стратегии. Она в том, что вы запускаете поиск на движке, построенном для одного честного прогона, — и вы были бы быстрее с циклом на pandas, который вам было слишком стыдно оставить.
Полный стенд, утверждения паритета и сырые результаты JSON по каждому движку живут в benchmarks/bench_oss_engines.py и benchmarks/results_oss/ на коммите 250dbb5. Про компилируемый/GPU-конец лесенки см. лесенку скорости движков для бэктестов и анализ IPC-налога.
Авторы
Инженер торговых систем
Разработка торговых ботов с 2017 года: межбиржевой арбитраж (подключал до 30 бирж), парный арбитраж на коинтеграции между спотом и фьючерсами, скальпинг, фронтраннинг, торговля по новостям, сентиментный анализ, трендовые алгоритмы, а также алгоритмы управления и балансировки портфелей. Делает выставление ордеров до 1 мс, warehouse для big data, бэктестинг-движки, AI-агентов и интерфейсы для ботов (в т.ч. open-source profitmaker.cc). Стек: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, архитектура.