← К списку статей
July 3, 2026
5 мин. чтения

Двухосевое пространство параметров: почему большая часть вашего перебора должна быть почти бесплатной

Двухосевое пространство параметров: почему большая часть вашего перебора должна быть почти бесплатной
#алготрейдинг
#бэктест
#поиск параметров
#оптимизация
#кэширование
#проклятие размерности
Часть 4 из 10 · Подборка
Быстрые движки бэктеста

Часть серии "Бэктесты без иллюзий".

Проклятие размерности обычно преподносят как предостережение: каждый добавленный параметр умножает пространство поиска, так что 18-мерную стратегию перебирать безнадежно. Эта формулировка молча предполагает, что оценка каждого измерения стоит одинаково. Это не так. В нашем движке для двух-трех таймфреймов две трети параметров перебираются почти бесплатно, а одна треть несет на себе практически весь счет за вычисления. Как только вы увидите это разделение, жалоба "пространство поиска слишком большое" перестает быть правильной. Правильный вопрос: за какие измерения вы платите?

Измерение, которое не нужно было пересчитывать

Наш бенчмарк поиска параметров запускает моментум-стратегию на нескольких таймфреймах: скользящую среднюю Халла и ее трижды сглаженный вариант (HMA3) на двух или трех таймфреймах, с воротами по направленному разделению (separation), которые решают, достаточно ли "чист" пересечение (crossover), чтобы на него реагировать. Полное пространство поиска для варианта с тремя таймфреймами — восемнадцать измерений: период и длина HMA для каждого таймфрейма, плюс набор порогов разделения для входа и выхода на каждом таймфрейме.

Наивный способ перебора — единый цикл: выбираем вектор параметров, строим все индикаторы с нуля, запускаем симуляцию, оцениваем результат, повторяем. Мы начали именно так. Это было медленно, и причина медлительности оказалась неловкой, как только мы ее назвали: для подавляющего большинства соседних испытаний мы пересчитывали индикаторы, которые не изменились.

Сдвиньте порог разделения на входе с 0.03 до 0.035 и перезапустите. Скользящая средняя Халла за год минутных баров побитово идентична предыдущему испытанию — порог нигде не фигурирует в ее определении. Тем не менее наивный цикл все равно перестраивает ее, по полумиллиону баров, на трех таймфреймах, каждый раз заново. Мы тратили почти весь вычислительный бюджет на повторный вывод величин, инвариантных к параметру, который на самом деле варьировался.

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

Две оси, а не одна

Две оси пространства параметров с разной ценой: разреженная дорогая сетка индикаторов, каждая ячейка которой открывает плотный, почти бесплатный лист конфигураций порогов

Разделим параметры по тому, что заставляет пересчитывать их повторная оценка:

  • Дорогая ось — параметры индикаторов. Период таймфрейма и длина HMA. Измените любой из них — и придется перестроить индикатор по всему ценовому ряду: пересемплировать старший таймфрейм, заново применить взвешивание Халла, пересчитать ряд пересечений и разделение на каждом пересечении. Это проход O(n) по каждому бару истории, и вы платите за него полностью на каждой отдельной комбинации. В стратегии с тремя таймфреймами эта ось — шесть параметров: (period, hma_length) для каждого из старшего, среднего и младшего таймфреймов.

  • Дешевая ось — пороги решений. Ворота по направленному разделению, которые решают, опираясь на уже посчитанные ряды пересечений и разделения, входить в позицию или выходить. Измените порог — ни один индикатор не сдвинется. Вы лишь повторно запускаете один проход O(n) по уже посчитанным сигналам, проверяя ворота и фиксируя исполнения. В стратегии с тремя таймфреймами эта ось — двенадцать параметров (четыре порога разделения — вход-покупка, вход-продажа, выход-покупка, выход-продажа — на каждом из трех таймфреймов). У варианта с двумя таймфреймами их восемь (четыре порога на каждом из двух таймфреймов).

Так что реальная форма пространства такова: 6 дорогих + 12 дешевых = 18 для варианта с тремя таймфреймами и 4 дорогих + 8 дешевых = 12 для варианта с двумя. Две трети размерности приходится на дешевую ось. И эти две оси различаются не только по количеству — они различаются по цене за единицу более чем на три порядка, и именно об этой цифре пойдет речь в остальной части статьи.

Структура строго вложенная. Каждая точка на дорогой оси — конкретная комбинация (period, hma_length) для каждого таймфрейма — задает набор фиксированных массивов сигналов. Поверх этого фиксированного фундамента лежит целый лист дешевой оси: тысячи векторов порогов, каждый — быстрый проход по тем же массивам. Вы платите за фундамент один раз и амортизируете эту плату на весь лист.

Почему индикаторы не зависят от порогов

Кэширование работает только благодаря математическому факту, а не удачной детали реализации, и его стоит сформулировать точно, потому что это несущее предположение. Массивы индикаторов — функция исключительно от (period, hma_length). Пороги разделения нигде не фигурируют в их определении.

Конкретно: для каждого таймфрейма движок заранее считает четыре выровненных по базовому индексу массива: скользящую среднюю Халла hma, ее трижды сглаженный вариант hma3, ряд пересечений cross (+1 покупка / −1 продажа / 0) и процент separation на каждом пересечении. Каждый из них выводится из цен и двух параметров индикатора. Порог — число, с которым сравнивается separation, — применяется позже, на этапе принятия решения. Он никогда не касается самих массивов.

Это не перестановка, безопасность которой очевидна сама по себе. Она безопасна только потому, что мы тщательно следили за тем, когда становится доступна информация — это тема работы про look-ahead bias в другой статье этой серии. Индикатор старшего таймфрейма на базовом баре i считается из закрытых свечей старшего ТФ плюс формирующейся свечи, текущее закрытие которой равно текущему закрытию базового бара close[i] — значению, известному на баре i. Из будущего ничего не просачивается. Поэтому заранее посчитанный сигнал на баре i — это ровно то, что увидел бы живой бот на баре i, и он остается верным независимо от того, какой порог вы потом к нему примените. Кэширование сигнала с утечкой просто закэшировало бы саму утечку; кэширование каузального сигнала кэширует то, чем реально можно торговать.

Эта инвариантность дает нам факторизацию. Запишем оценку одного полного вектора параметров θ = (indicator_params, threshold_params) как:

signals   = build_indicators(indicator_params)      # EXPENSIVE, depends only on indicator_params
score     = simulate(signals, threshold_params)     # CHEAP, reuses signals across all thresholds

build_indicators не читает threshold_params. Именно этот единственный факт узаконивает кэш: зафиксируйте indicator_params, свободно варьируйте threshold_params, и signals окажется константой, которую вы вычисляете один раз.

Архитектура: посчитать один раз, перебрать много раз

Кэш сигналов, ключом которого служат параметры индикаторов, питает быстрый пакетный перебор: одна сборка индикаторов разворачивается в широкий ряд оценок порогов, использующих одни и те же кэшированные массивы

Движок (scripts/engine_multitf.py, коммит bfc8aaa в нашем бэктестере) реализует эту факторизацию двумя частями, чьи названия точно описывают, что они делают.

SignalCache — дорогая ось, мемоизированная. Это словарь с ключом (period_bars, hma_length). Запросите у него сигналы для таймфрейма — и он вернет кэшированный TFSignals, если такая комбинация индикаторов уже строилась раньше, либо построит их один раз и сохранит. Поскольку ключом служат только параметры индикаторов, каждая конфигурация порогов, разделяющая с другими одну и ту же комбинацию индикаторов — а в плотном переборе порогов таких тысячи — попадает в одну и ту же запись кэша. Старшие таймфреймы особенно выигрывают от этого: грубая сетка, скажем, из четырех кандидатных периодов на горстку длин HMA — это небольшое число различных дорогих сборок, и каждая из них переиспользуется на протяжении всего листа порогов, надстроенного поверх нее.

sweep_separations — дешевая ось, пакетная. Она берет кэшированные массивы сигналов и матрицу векторов порогов (sps, форма [m, 12]) и прогоняет их все через единое скомпилированное ядро. Каждая строка — один проход O(n): идем по барам, проверяем ворота, фиксируем исполнения без утечек по open[i+1], считаем PnL и время в позиции. Внутри этого цикла ни один индикатор не перестраивается — он читает cross и separation прямо из кэша. Внутренняя симуляция JIT-компилируется (Numba), так что после прогрева стоимость на конфигурацию определяется одним линейным проходом по барам, а не накладными расходами Python.

Эти две части складываются в естественный вложенный поиск: внешний цикл идет по дорогой оси (на каждой итерации строится и кэшируется одна комбинация индикаторов), а внутренний цикл разворачивает широкий пакет по дешевой оси поверх этих кэшированных массивов. В коде форма именно такая — небольшой дорогой цикл, оборачивающий широкий дешевый пакет:

cache = SignalCache(base_close, base_ts)           # keyed by (period, hma_length)

for htf_p, htf_h, mtf_p, mtf_h, ltf_p, ltf_h in indicator_grid:   # EXPENSIVE axis (coarse)
    htf = cache.get(htf_p, htf_h)                  # built once, then a cache hit forever
    mtf = cache.get(mtf_p, mtf_h)
    ltf = cache.get(ltf_p, ltf_h)

    sps = sample_thresholds(m=4000)                # CHEAP axis: [m, 12] threshold vectors
    pnl, n_trades, bars_in_pos = sweep_separations(  # one compiled batch, no indicator work
        base_close, base_open, htf, ltf, sps, mtf=mtf)

Вы обращаетесь к дорогому строителю ровно столько раз, сколько позволяет сетка индикаторов, а весь объем берет на себя дешевый перебор — тысячи строк sps против массивов, которые никогда не меняются. В этом вся оптимизация целиком — никаких приближений, никакой потери точности, только отказ пересчитывать то, что не изменилось.

Сколько на самом деле стоит "бесплатное": цифры

Столбчатое сравнение стоимости на конфигурацию: высокий столбец для пересчета на каждую конфигурацию рядом с тонкой полоской для кэшированного прохода порогов, с пометкой о разрыве примерно в 1600 раз

Мы замерили обе оси на демонстрационной нагрузке: полный год минутных баров ETHUSDT (~527 тыс. баров), три таймфрейма, индикаторы прогреты, JIT-компиляция вынесена за пределы замера.

На дешевой оси sweep_separations держит ~5600 конфигураций порогов в секунду. Это полная симуляция — ворота, исполнения, PnL, экспозиция — для каждой конфигурации, по полумиллиону баров, примерно за 180 микросекунд на конфигурацию. Причина такой скорости в том, что здесь ноль работы с индикаторами: каждая конфигурация читает одни и те же кэшированные массивы cross и separation.

Теперь оценим альтернативу. Однократная сборка набора индикаторов для трех таймфреймов занимает порядка нескольких сотен миллисекунд (~0.3 с) — пересемплирование трех таймфреймов, взвешивание Халла, извлечение пересечений и разделения за весь год. Если пересчитывать индикаторы внутри цикла по конфигурациям — как в наивной однопроходной схеме, — каждая из этих 5600 конфигураций в секунду вместо этого платила бы за полную сборку индикаторов. Стоимость на конфигурацию раздувается с ~180 микросекунд до ~0.3 секунды:

Стоимость на конфигурацию Конфигураций/с
Дешевая ось (кэшированные сигналы) ~180 мкс ~5600
Пересчет индикаторов на каждую конфигурацию ~0.3 с ~3.4

Соотношение — ~1600x. Перебор дешевой оси на кэшированных сигналах примерно на три порядка дешевле наивной схемы, которая перестраивает индикаторы для каждого вектора порогов. Конкретно: пакет из нескольких тысяч конфигураций порогов, который на кэшированном пути укладывается меньше чем в секунду, на схеме с пересборкой индикаторов на каждую конфигурацию занял бы большую часть часа. Те же результаты, та же точность, никаких срезанных углов в математике — единственная разница в том, что одна схема пересчитывает инвариант, а другая нет.

Это не микрооптимизация, которую добавляют под конец. Она меняет то, какие поиски вообще осуществимы. При 5600 конфиг/с ось порогов достаточно плотна, чтобы исследовать ее по-настоящему — вы можете позволить себе мелкую сетку или длинную случайную/QMC-выборку на каждую комбинацию индикаторов, — тогда как дорогая ось остается намеренно грубой горсткой сборок. Вычислительный бюджет утекает туда, где параметры действительно чего-то стоят.

Проклятие размерности, переоцененное

Плоская однородная сетка параметров, перемаркированная в двухуровневую карту цен: несколько дорогих ячеек с жирной обводкой, остальные закрашены как почти бесплатные

Вернемся к формулировке, с которой началась эта статья. Проклятие размерности гласит, что пространство поиска растет экспоненциально с числом параметров, так что больше измерений — категорически хуже. Это верно в отношении размера сетки. Но это вводит в заблуждение в отношении стоимости ее покрытия, потому что оценивает каждое измерение одинаково.

Как только вы разделяете оси, счет читается иначе. Стратегия с тремя таймфреймами имеет 18 измерений, но лишь 6 из них дорогие. Остальные 12 — измерения дешевой оси, которые расширяют сетку, не расширяя сколько-нибудь заметно счет за вычисления — на них можно обрушить тысячи конфигураций за копейки. Стратегия с двумя таймфреймами имеет 12 измерений, из которых лишь 4 дорогие, а 8 дешевых. В обоих случаях большая часть "проклятия" сосредоточена на оси, перебор которой почти ничего не стоит.

Так что честный способ рассуждать о стоимости поиска — не "сколько параметров", а "сколько дорогих параметров и насколько грубой может быть их сетка". Именно на дорогой оси экспонента по-настоящему бьет, и именно там нужна небольшая, тщательно подобранная сетка — несколько кандидатных периодов, скромный диапазон длин HMA, — возможно, уточняемая от грубого к мелкому, в духе адаптивного дробления разрешения, которое мы используем в другой статье. На дешевой оси можно позволить себе роскошь, потому что каждая дополнительная конфигурация порогов стоит 180 микросекунд.

Эта переоценка обобщается далеко за пределы нашей HMA-стратегии. Закономерность — одни параметры меняют признаки, большинство параметров меняют правило, применяемое к зафиксированным признакам — повторяется повсюду в системном трейдинге. Длины индикаторов, частоты пересемплирования и окна ретроспективного взгляда (lookback) дороги; пороги входа/выхода, дистанции стопов, множители размера позиции и подтверждающие ворота дешевы. Всякий раз, когда параметр лишь переформирует границу решения поверх уже посчитанных сигналов, он относится к дешевой оси, и перебирать его нужно именно там. Родственные выигрыши от кэширования из того же семейства встречаются в кэшировании parquet для нескольких таймфреймов и в более широкой лестнице скорости движка бэктестов.

Где у бесплатного обеда есть счет

Дешевая ось дешева по вычислениям. Она не дешева по статистике, и смешение этих двух вещей — верный способ превратить выигрыш в производительности в машину переобучения.

Каждая оцениваемая конфигурация порогов — это испытание, и когда вы прогоняете тысячи испытаний на одном наборе данных, лучшее из них выглядит хорошо отчасти благодаря везению. Удешевление этих испытаний в 1600 раз не убирает везение — оно облегчает его накопление. Плотный перебор порогов — это как раз та ситуация, где инфляция множественного тестирования кусается сильнее всего: множество кандидатов, одна история и правило отбора, которое сообщает максимум. Самый быстрый движок в мире с радостью вручит вам вектор порогов, который прекрасно подстроился под шум вашего тестового окна и провалится вне выборки.

Поэтому дисциплина должна масштабироваться вместе со скоростью. В тот момент, когда вычислительная стоимость испытания стремится к нулю, связывающим ограничением становится статистический учет, и платить за него приходится явно:

  • Дефлируйте на число испытаний. Оценивайте победителя относительно того, сколько конфигураций вы перебрали, а не относительно нуля. Дефлированный коэффициент Шарпа и вероятность переобучения бэктеста существуют именно для этого — они превращают фразу "мы перебрали 4000 векторов порогов" в скидку с заявленного преимущества.
  • Проверяйте вне выборки, по каждому фолду. Даже дешевый перебор нужно проводить внутри честного разбиения walk-forward; порог, который побеждает только внутри выборки, бесполезен, как бы быстро вы его ни нашли. Наш движок держит исполнения без утечек (open[i+1]) именно для того, чтобы дешевая ось не могла купить результат подглядыванием.
  • Предпочитайте плато пикам. Поскольку ось порогов плотная и быстрая, вы можете картировать всю ее поверхность отклика, а не только ее argmax. Широкая область порогов, которые все работают, — это настоящее преимущество; одиночный острый максимум — это подогнанный артефакт. Именно различие плато против пика становится доступным по цене благодаря той самой скорости, о которой мы говорим.

Правильно читайте двухосевую структуру: она не покупает вам больше уверенности, она покупает вам больше испытаний по той же цене уверенности за испытание. Это по-настоящему ценно — именно плотное покрытие дешевой оси позволяет находить плато и описывать поверхность отклика, — но только если вы честно ведете статистическую бухгалтерию. Скорость убирает вычислительное оправдание не искать тщательно; она не убирает обязанность дисконтировать то, что находит тщательный поиск.

Как выстроить свой собственный поиск

Чтобы применить это к своей собственной стратегии, основная работа сводится к классификации — распределению своих параметров по нужным осям, — за которой следует вложенный цикл:

  1. Пометьте каждый параметр по тому, что он заставляет пересчитывать. Если его изменение меняет массив индикатора/признака — он дорогой. Если он лишь меняет правило сравнения, порога или размера позиции, применяемое к фиксированным массивам, — он дешевый. В случае сомнений спросите: фигурирует ли этот параметр где-либо внутри определения индикатора? Если нет — он дешевый.
  2. Стройте ключ кэша только по дорогой оси. Мемоизируйте построение признаков по параметрам индикатора (как это делает SignalCache по (period, hma_length)). Соседние испытания, разделяющие одну комбинацию индикаторов, тогда бесплатно переиспользуют одни и те же массивы.
  3. Пакетируйте дешевую ось поверх кэшированных признаков. Прогоняйте конфигурации порогов как плотный скомпилированный цикл по уже посчитанным сигналам, а не как полные повторные вычисления. Именно отсюда берется ваша пропускная способность — в нашем случае ~5600 конфиг/с.
  4. Вложите циклы: дорогой снаружи, дешевый внутри. Держите дорогую сетку небольшой и продуманной (грубой либо уточняемой от грубого к мелкому); пусть дешевый перебор будет плотным. Тратьте бюджет там, где параметры чего-то стоят.
  5. Планируйте бюджет испытаний исходя из переобучения, а не из времени на часах. Теперь, когда время на часах перестало быть ограничением, пусть им станет дефлированный Шарп / PBO. Определите, сколько испытаний по дешевой оси вы можете себе позволить статистически, и проверьте победителя вне выборки.

Инженерная выгода и статистическое ограждение — две стороны одной идеи: разделение осей позволяет исчерпывающе перебрать дешевые измерения, и именно поэтому потом нужно дисконтировать то, насколько исчерпывающим был этот перебор.

Выводы

  1. Не все измерения стоят одинаково. Параметры стратегии делятся на дорогую ось (индикаторы — пересчитываются по всему ряду) и дешевую ось (пороги — проход O(n) по уже посчитанным сигналам). В нашем движке это 6 дорогих + 12 дешевых для варианта с тремя таймфреймами и 4 + 8 для варианта с двумя.
  2. Индикаторы не зависят от порогов, и эта инвариантность — вся оптимизация целиком. Построение признаков зависит только от параметров индикатора, поэтому вы строите один раз, кэшируете по (period, hma_length) и переиспользуете для каждой конфигурации порогов, разделяющей эту комбинацию.
  3. Дешевая ось работает примерно в 1600 раз дешевле. ~5600 конфигураций порогов в секунду (~180 мкс каждая) на кэшированных сигналах против ~0.3 с на конфигурацию, если пересобирать индикаторы каждый раз. Та же точность — единственная разница в отказе пересчитывать инвариант.
  4. Проклятие размерности на самом деле — проклятие дорогой размерности. Большая часть параметров живет на оси, перебор которой почти бесплатен. Держите дорогую сетку грубой; на дешевой не скупитесь.
  5. Скорость смещает связывающее ограничение с вычислений на статистику. Плотный, быстрый перебор — это машина множественного тестирования. Дефлируйте на число испытаний, проверяйте по каждому фолду и предпочитайте плато пикам — бесплатный обед реален, но статистический счет не опционален.

Полный движок — SignalCache, sweep_separations, симуляция для нескольких таймфреймов без утечек и тест на паритет, который привязывает ее к семантике живой формирующейся свечи, — находится в scripts/engine_multitf.py (коммит bfc8aaa) в нашем бэктестере. В следующий раз, когда кто-то скажет вам, что 18-параметрическую стратегию слишком сложно перебирать, спросите его, сколько из этих параметров реально двигают индикаторы. Обычно это треть, а остальное — почти бесплатно.

Дисклеймер: Информация в этой статье предоставлена исключительно в образовательных и ознакомительных целях и не является финансовым, инвестиционным или торговым советом. Торговля криптовалютами сопряжена с высоким риском убытков.

Авторы

Eugen Soloviov
Eugen Soloviov

Инженер торговых систем

Разработка торговых ботов с 2017 года: межбиржевой арбитраж (подключал до 30 бирж), парный арбитраж на коинтеграции между спотом и фьючерсами, скальпинг, фронтраннинг, торговля по новостям, сентиментный анализ, трендовые алгоритмы, а также алгоритмы управления и балансировки портфелей. Делает выставление ордеров до 1 мс, warehouse для big data, бэктестинг-движки, AI-агентов и интерфейсы для ботов (в т.ч. open-source profitmaker.cc). Стек: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, архитектура.

Newsletter

Будьте в курсе событий

Подпишитесь на нашу рассылку, чтобы получать эксклюзивную аналитику по AI-трейдингу и обновления платформы.

Мы уважаем вашу конфиденциальность. Отписаться можно в любой момент.