Альтернатива временным барам (Часть 2): Построение баров серий в MQL5
Содержание
- Введение
- Краткий обзор — что такое бары серий и зачем они нужны
- Реализация на Python — make_bars()
- Калибровка и проблема инициализации
- Бары серий и бары дисбаланса — статистическое сравнение
- Реализация на MQL5 — CRunsBar
- Сохранение состояния и обработка разрывов
- Проверка совпадения
- Практические рекомендации и эвристики выбора
- Заключение
- Прикрепленные файлы
Введение
В предыдущей статье были реализованы все десять типов баров из главы 1 книги Лопеса де Прадо "Advances in Financial Machine Learning", включая тиковые, объемные и долларовые бары дисбаланса. Одно семейство было рассмотрено менее подробно, чем остальные, — бары серий. Эта статья дополняет общую картину.
Бары серий близки к барам дисбаланса. Бар дисбаланса закрывается, когда накопленная величина с учетом знака — сумма по всем тикам, каждый из которых взвешен по своему направлению, — превышает порог. Бар серий закрывается, когда накопленная величина, рассчитанная только по тикам доминирующего направления, превышает порог. Это различие важно: бары дисбаланса реагируют на чистое направленное давление, которое может скрываться при больших и примерно равных объемах покупок и продаж. Бары серий реагируют на абсолютную величину активности доминирующей стороны, поэтому чувствительны к односторонним всплескам активности, даже когда противоположное тоже активно.
Используется та же архитектура, что и в предыдущей статье: в реализации на Python модуль afml.data_structures дополнен тремя типами баров серий, доступными через make_bars(); в реализации на MQL5 добавлен CRunsBar с потиковой логикой и тем же интерфейсом сохранения состояния. Проверки совпадения подтверждают идентичность последовательностей баров при одном и том же потоке тиков.
В данной статье рассматриваются четыре аспекта реализации. Во-первых, в ней объясняется, чем отличаются рекуррентные формулы для баров серий и баров дисбаланса и почему это важно в коде. Во-вторых, рассматривается инициализация баров серий и соответствующее расширение калибровки через target_timeframe. В-третьих, указаны дополнительные данные состояния, которые нужно сохранять между перезапусками советника. В-четвертых, в ней описано, в каких случаях бары серий существенно отличаются от баров дисбаланса.
Краткий обзор — что такое бары серий и зачем они нужны
Бары дисбаланса закрываются, когда чистый поток с учетом знака превышает порог. Если в окне из 200 тиков 110 были инициированы покупателями, а 90 — продавцами, то накопленный дисбаланс θT равен 110 - 90 = 20. Бар закрывается, когда |θT| ≥ порог. В барах серий используется другой накопитель. Вместо взаимного зачета вкладов покупок и продаж накопление ведется отдельно по двум направлениям. Бар закрывается, когда большая из двух накопленных сумм превышает порог:
θT+ = Σ bt · metrict for all t where bt = +1 θT− = Σ |bt · metrict| for all t where bt = −1 Close when max(θT+, θT−) ≥ E0[T] · max(E0[θ+], E0[θ−])
В приведенном выше примере θT+ = 110, а θT- = 90. Порог бара серий применяется к max(110, 90) = 110. Порог бара дисбаланса применяется к |110 − 90| = 20. При прочих равных для закрытия бара серий требуется большее абсолютное значение, однако накопленная сумма растет быстрее, поскольку из нее не вычитается поток противоположного направления. Будет ли в результате сформировано больше или меньше баров, чем баров дисбаланса, зависит от рыночного режима.
На практике различие проявляется скорее в поведении баров, чем в строгих статистических характеристиках. В пилообразном режиме с возвратом к среднему потоки покупок и продаж быстро чередуются, а дисбаланс θT долго остается близким к нулю, поэтому формируется очень мало баров. Значения накопителей серий θT+ и θT- устойчиво растут независимо от чередования, поэтому бары серий продолжают закрываться с более регулярной частотой. В режиме сильного тренда оба типа баров закрываются часто, однако бар серий реагирует на накопленную величину доминирующего направления, а не на чистую разность. Поэтому он менее чувствителен к шуму противоположного направления. Эти два типа баров содержат взаимодополняющую информацию и по этой причине оба включены в библиотеку afml.data_structures.bars.

Рис. 1. Иллюстрация из двух панелей, показывающая траектории значений накопителей на потоке из 600 тиков с тремя режимами.
- Панель (a) — накопитель дисбаланса θT (синий) в пилообразном центральном участке (выделен). Строгое чередование тиков покупок и продаж удерживает θT около нуля на всем участке, поэтому в этом режиме не закрывается ни один бар.
- Панель (b) — накопитель серий доминирующего направления max(θ+, θ-) (оранжевый). Накопление по обоим направлениям идет независимо от чередования; сумма доминирующего направления трижды достигает порога на том же пилообразном участке, который бар дисбаланса полностью пропускает. Зеленые вертикальные линии обозначают все закрытия баров; три закрытия баров серий в пилообразном режиме отмечены подписями.
Реализация на Python — make_bars()
Детектор границ баров серий использует тот же шаблон JIT-компиляции с Numba, что и функция _detect_imbalance_boundaries(). Состояние накопителя расширяется с одного скаляра (θ) до трех компонентов: θT+, θT- и оценок EWM для ожидаемых вкладов на тик по обеим сторонам. Для обновления EWM при закрытии каждого бара требуется не одна, а две отдельные рекуррентные формулы:
@njit(cache=True) def _detect_runs_boundaries( metric: np.ndarray, exp_ticks_init: float, exp_runs_buy_init: float, exp_runs_sell_init: float, ewm_alpha: float, ) -> np.ndarray: n = len(metric) boundaries = np.empty(n, dtype=np.int64) n_bars = 0 theta_buy = 0.0 theta_sell = 0.0 bar_start = 0 ewm_T = exp_ticks_init ewm_runs_buy = exp_runs_buy_init * exp_ticks_init ewm_runs_sell = exp_runs_sell_init * exp_ticks_init exp_T = exp_ticks_init exp_buy = exp_runs_buy_init exp_sell = exp_runs_sell_init one_minus_alpha = 1.0 - ewm_alpha for t in range(n): v = metric[t] if v >= 0.0: theta_buy += v else: theta_sell += -v threshold = exp_T * max(exp_buy, exp_sell) if max(theta_buy, theta_sell) >= threshold: boundaries[n_bars] = t n_bars += 1 bar_len = float(t - bar_start + 1) ewm_T = ewm_alpha * bar_len + one_minus_alpha * ewm_T ewm_runs_buy = ewm_alpha * theta_buy + one_minus_alpha * ewm_runs_buy ewm_runs_sell = ewm_alpha * theta_sell + one_minus_alpha * ewm_runs_sell exp_T = ewm_T exp_buy = ewm_runs_buy / max(exp_T, 1.0) exp_sell = ewm_runs_sell / max(exp_T, 1.0) theta_buy = 0.0 theta_sell = 0.0 bar_start = t + 1 return boundaries[:n_bars]
Величина с учетом знака, передаваемая в эту функцию, совпадает с величиной для соответствующего типа баров дисбаланса: bt для тиковых баров серий, bt · vt для объемных и bt · pt · vt для долларовых. Разница только в обновлении накопителя: для баров дисбаланса величина с учетом знака добавляется непосредственно к θ, а для баров серий положительные значения добавляются к θT+, абсолютные значения отрицательных — к θT-. Это означает, что ядро тикового правила используется без изменений; меняется только детектор границ.
Этап агрегирования после определения границ остается таким же, как для баров дисбаланса. np.add.reduceat() агрегирует массив тиков по найденным индексам границ, формируя цены открытия, максимума, минимума и закрытия (OHLC), суммы объемов и средние спреды за один векторизованный проход. Полученный DataFrame имеет ту же схему столбцов, что и результаты для всех остальных типов баров, возвращаемые make_bars().
В диспетчере make_bars() зарегистрированы три новых строковых обозначения типов баров:
# Runs bars — preferred: auto-calibrated via target_timeframe tick_runs_bars = make_bars(tick_df, bar_type="tick_runs", target_timeframe="M15") vol_runs_bars = make_bars(tick_df, bar_type="volume_runs", target_timeframe="M15") dollar_runs_bars = make_bars(tick_df, bar_type="dollar_runs", target_timeframe="M15") # Runs bars — advanced: explicit seeds bypass calibration dollar_runs_bars = make_bars( tick_df, bar_type="dollar_runs", exp_ticks_init=11_700, exp_imbalance_init=0.018, # seeded symmetrically to both buy and sell ) # Or, if separate buy/sell seeds have been calibrated offline: dollar_runs_bars = make_bars( tick_df, bar_type="dollar_runs", exp_ticks_init=11_700, exp_runs_buy_init=0.020, exp_runs_sell_init=0.016, )
Параметр exp_imbalance_init используется и для баров серий. Если не заданы отдельные начальные значения для каждого направления, он интерпретируется симметрично: и E0[θ+/T], и E0[θ-/T] инициализируются одним и тем же значением. Это упрощение; на реальных рынках интенсивности покупок и продаж редко бывают точно равны. При калибровке, описанной в следующем разделе, эти значения определяются отдельно; при явном задании начальных значений их можно передать напрямую через exp_runs_buy_init и exp_runs_sell_init.
Калибровка и проблема инициализации
Калибратор баров дисбаланса в функции _calibrate_information_bar_params() определяет одну начальную оценку дисбаланса |E0[bt]| по величине с учетом знака. Для баров серий требуются две оценки — E0[θ+/T] и E0[θ-/T], которые определяются отдельно по распределениям величин для покупок и продаж. Функция калибровки возвращает кортеж из трех чисел типа float:
def _calibrate_runs_bar_params( tick_df: pd.DataFrame, bar_type: str, target_timeframe: str, price_col: str = "mid_price", max_ticks: int = 500_000, verbose: bool = True, ) -> tuple[int, float, float]: from .bars import calculate_ticks_per_period from .information_bars import _tick_rule exp_ticks_init = calculate_ticks_per_period( tick_df, timeframe=target_timeframe, method="median", verbose=False ) n = min(max_ticks, len(tick_df)) sample = tick_df.iloc[:n] prices = sample[price_col].to_numpy(dtype=np.float64) b = _tick_rule(prices) if bar_type == "tick_runs": metric = b elif bar_type == "volume_runs": metric = b * sample["volume"].to_numpy(dtype=np.float64) else: # "dollar_runs" metric = b * sample[price_col].to_numpy(dtype=np.float64) * \ sample["volume"].to_numpy(dtype=np.float64) buy_mask = metric > 0 sell_mask = metric < 0 exp_buy = float(np.mean(metric[buy_mask])) if buy_mask.any() else 1e-6 exp_sell = float(np.mean(np.abs(metric[sell_mask]))) if sell_mask.any() else 1e-6 exp_buy = max(exp_buy, 1e-6) exp_sell = max(exp_sell, 1e-6) if verbose: logger.info( f"Auto-calibration for {bar_type} at {target_timeframe}: " f"exp_ticks_init={exp_ticks_init:,}, " f"exp_runs_buy_init={exp_buy:.4g}, exp_runs_sell_init={exp_sell:.4g}" ) return exp_ticks_init, exp_buy, exp_sell
Нижняя граница 1e-6 предотвращает снижение порога до нуля на начальной выборке с тиками только одного направления. На практике любой набор тиковых данных, достаточно длинный для надежного определения E0[T] по медианной плотности тиков, также содержит достаточно потока обоих направлений для получения невырожденных оценок покупок и продаж. Эта нижняя граница служит защитным механизмом для синтетических тестовых данных и очень короткой истории.
Есть тонкое отличие от калибровки баров дисбаланса: в ней оценка |E0[bt]| определяется как эмпирическая доля тиков со знаком +1 минус доля тиков со знаком -1, а затем ограничивается снизу значением 0,01. При калибровке баров серий отдельно используются средние значения величин для покупок и продаж, без взаимного вычитания. Для тиковых баров серий, где bt ∈ {+1, −1}, это сводится соответственно к доле восходящих и нисходящих тиков — взаимодополняющим величинам с суммой 1,0. Для объемных и долларовых баров серий средние значения выражаются в соответствующих единицах (единицах объема и валюты), и порог получает те же единицы измерения.
Здесь применима та же рекомендация по офлайн-калибровке, что и в предыдущей статье. Выполните калибровку на Python по полному многолетнему набору данных Parquet, запишите exp_ticks_init, exp_runs_buy_init и exp_runs_sell_init в конфигурационный JSON-файл и явно передайте эти значения советнику MQL5. Не полагайтесь на CopyTicksRange() в OnInit() для калибровки баров серий по локальному кэшу тиков терминала: короткая история смещает E0[T] в сторону недавних рыночных условий и может занизить среднее для продаж, если кэшированное окно начинается в середине тренда.
Бары серий и бары дисбаланса — статистическое сравнение
Выбор между барами серий и барами дисбаланса для конкретной задачи — вопрос скорее эмпирический, чем теоретический: оба типа опираются на одну и ту же литературу о направленном потоке. Прежде чем выбрать один из этих типов для рабочего пайплайна, стоит рассчитать три диагностических показателя.
Стабильность числа баров в различных режимах
Постройте график числа баров в час за несколько дней. В периоды низкой волатильности и возврата к среднему формируется очень мало баров дисбаланса, поскольку чистое значение θ остается близким к нулю. Бары серий продолжают закрываться: ни θ+, ни θ- не уменьшаются потоком противоположного направления. Если последующей модели нужна минимальная плотность наблюдений — например, оценивателю волатильности для надежной работы требуется не менее пяти баров в час, — то во время ночных сессий более безопасный выбор — бары серий.
Нормальность распределения доходностей
Примените тест Жарка — Бера к доходностям между закрытиями баров. Ни бары серий, ни бары дисбаланса не дают идеально гауссовых доходностей, но оба типа должны превосходить временные бары. На трендовых рынках оба типа дают схожие распределения. В пилообразных рыночных режимах бары дисбаланса дают распределение доходностей с тяжелыми хвостами: очень длинные бары (сформированные в периоды низкого дисбаланса) смешиваются с очень короткими (сформированными во время кратких направленных всплесков). Бары серий дают более однородное распределение длины баров и, соответственно, более стабильную дисперсию доходностей.
Серийная автокорреляция
Рассчитайте автокорреляцию первого порядка доходностей баров. Бары на основе активности предназначены для снижения серийной корреляции по сравнению с временными барами, однако степень снижения зависит от режима. На ликвидных валютных рынках долларовые бары серий обычно снижают автокорреляцию первого порядка на 40-60% по сравнению с одноминутными временными барами. Долларовые бары дисбаланса снижают ее на 50-70% за тот же период. Во время сессий с выраженным трендом разница сокращается. Ни один из этих типов баров не устраняет автокорреляцию полностью, поскольку тиковое правило — зашумленная косвенная оценка истинного направления сделки.

Рис. 2. Иллюстрация из трех панелей, сравнивающая долларовые бары серий и долларовые бары дисбаланса на пятидневном потоке тиков с ночными сессиями.
- Панель (a) — число баров по часам. Для баров серий (оранжевые) число баров остается выше контрольной линии (трех баров в час) на протяжении каждого ночного окна (выделено); для баров дисбаланса (синие) в те же периоды оно снижается до менее чем одного бара в час.
- Панель (b) — QQ-график доходностей между закрытиями баров. Оба типа баров на основе активности ближе к эталонной линии нормального распределения, чем временные бары (серые). Квантили баров серий (оранжевые) меньше отклоняются в хвостах на всем пятидневном интервале, поскольку более стабильная длина баров дает более однородную дисперсию доходностей.
- Панель (c) — скользящая автокорреляция первого порядка с окном в 40 баров. Автокорреляция для обоих типов колеблется около нуля; для баров серий дисперсия оценки автокорреляции несколько ниже, что согласуется с более стабильной длиной баров.
Реализация на MQL5 — CRunsBar
CRunsBar наследуется от CBarConstructor по аналогии с CImbalanceBar, получая общий накопитель OHLC, структуру SBar для передачи бара и интерфейс SaveState/LoadState. Класс добавляет три накопителя сверх имеющихся в CImbalanceBar: m_theta_buy, m_theta_sell и отдельный скаляр EWM для ожидаемого вклада на тик каждого направления.
Прежде чем собрать CRunsBar, нужно внести одно изменение в CImbalanceBars.mqh: классу CTickRule нужны публичные сеттеры, чтобы метод CRunsBar::LoadState() мог при перезапуске восстанавливать prev_price и prev_sign тикового правила. В предыдущей статье CTickRule предоставлял только методы доступа для чтения. Добавлены два сеттера:
//--- In CImbalanceBars.mqh — addition to CTickRule void SetPrevPrice(const double p) { m_prev_price = p; } void SetPrevSign (const double s) { m_prev_sign = s; }
После добавления этих сеттеров CRunsBar объявляется следующим образом:
//--- CRunsBar.mqh class CRunsBar : public CBarConstructor { private: CTickRule m_tick_rule; ENUM_IMBALANCE_METRIC m_metric_type; double m_theta_buy; double m_theta_sell; double m_ewm_T; double m_ewm_runs_buy; double m_ewm_runs_sell; double m_exp_T; double m_exp_buy; double m_exp_sell; double m_ewm_alpha; long m_bar_tick_count; public: CRunsBar( ENUM_IMBALANCE_METRIC metric_type, double exp_ticks_init, double exp_runs_buy_init, double exp_runs_sell_init, int ewm_span = 20); virtual bool ProcessTick(const MqlTick &tick, const long tick_num, SBar &out_bar) override; virtual bool SaveState(const int file_handle) override; virtual bool LoadState(const int file_handle) override; double ThetaBuy(void) const { return m_theta_buy; } double ThetaSell(void) const { return m_theta_sell; } double CurrentThreshold(void) const { return m_exp_T * MathMax(m_exp_buy, m_exp_sell); } double ExpT(void) const { return m_exp_T; } double ExpBuy(void) const { return m_exp_buy; } double ExpSell(void) const { return m_exp_sell; } };
Метод ProcessTick() использует ту же последовательность из трех вспомогательных методов, что и CImbalanceBar, но обновление накопителя разделено на две условные ветви по направлению тика:
bool CRunsBar::ProcessTick(const MqlTick &tick, const long tick_num, SBar &out_bar) { if(!m_initialized) { SeedBar(tick, tick_num); return false; } UpdateAccumulator(tick, tick_num); m_bar_tick_count++; double sign = m_tick_rule.Classify(tick.bid); double metric = TickMetric(tick, sign); if(metric >= 0.0) m_theta_buy += metric; else m_theta_sell += -metric; double threshold = m_exp_T * MathMax(m_exp_buy, m_exp_sell); if(MathMax(m_theta_buy, m_theta_sell) >= threshold) { FillBar(out_bar); double bar_len = (double)m_bar_tick_count; m_ewm_T = m_ewm_alpha * bar_len + (1.0 - m_ewm_alpha) * m_ewm_T; m_ewm_runs_buy = m_ewm_alpha * m_theta_buy + (1.0 - m_ewm_alpha) * m_ewm_runs_buy; m_ewm_runs_sell = m_ewm_alpha * m_theta_sell + (1.0 - m_ewm_alpha) * m_ewm_runs_sell; m_exp_T = m_ewm_T; m_exp_buy = m_ewm_runs_buy / MathMax(m_exp_T, 1.0); m_exp_sell = m_ewm_runs_sell / MathMax(m_exp_T, 1.0); m_theta_buy = 0.0; m_theta_sell = 0.0; m_bar_tick_count = 0; SeedBar(tick, tick_num); return true; } return false; }
Одно проектное решение заслуживает пояснения. Метод SeedBar() вызывается после обновления EWM и сброса накопителя, а не до них. Это означает, что граничный тик (тик, вызвавший закрытие) становится первым тиком нового бара, а не последним тиком закрывающегося бара. Это соответствует соглашению в Python-функции _detect_runs_boundaries(), где bar_start = t + 1 задается после записи границы с индексом t, и сохраняет семантику баров дисбаланса из предыдущей статьи. Значения OHLC закрытого бара уже учитывают граничный тик благодаря вызову UpdateAccumulator() в начале ProcessTick(), до проверки условия закрытия. Такой же порядок используется в CImbalanceBar.
Приватный вспомогательный метод TickMetric() возвращает величину с учетом знака для текущего тика, выбранную по m_metric_type. В нем повторно используется перечисление ENUM_IMBALANCE_METRIC, определенное в CImbalanceBars.mqh, что позволяет избежать дублирования механизма выбора одной из трех метрик в обоих файлах. CRunsBar.mqh подключает файл CImbalanceBars.mqh вместо повторного объявления перечисления:
double CRunsBar::TickMetric(const MqlTick &tick, const double sign) const { switch(m_metric_type) { case IMBALANCE_TICK: return sign; case IMBALANCE_VOLUME: return sign * tick.volume; case IMBALANCE_DOLLAR: return sign * tick.bid * tick.volume; default: return sign; } }
В фабрике советника в BarBuilderEA.mq5 добавлены три новые ветви оператора switch. Перечисление типов баров теперь включает варианты баров серий. Фабрика также предусматривает резервный вариант симметричной инициализации: если InpExpRunsBuyInit и InpExpRunsSellInit оставлены со значением по умолчанию -1, вместо них используется InpExpImbInit. Это соответствует упрощенной инициализации в Python, когда передан только exp_imbalance_init.
//--- BarBuilderEA.mq5 — enum extension and factory excerpt enum ENUM_BAR_TYPE { BAR_TIME = 0, BAR_TICK = 1, BAR_VOLUME = 2, BAR_DOLLAR = 3, BAR_TICK_IMB = 4, BAR_VOLUME_IMB = 5, BAR_DOLLAR_IMB = 6, BAR_TICK_RUNS = 7, BAR_VOLUME_RUNS = 8, BAR_DOLLAR_RUNS = 9 }; //--- Inputs (excerpt) input double InpExpRunsBuyInit = -1; // initial E_0[theta_buy/T] for runs bars input double InpExpRunsSellInit = -1; // initial E_0[theta_sell/T] for runs bars //--- Factory (excerpt) CBarConstructor *CreateBarConstructor(void) { switch(InpBarType) { //--- ... standard and imbalance cases unchanged ... case BAR_TICK_RUNS: case BAR_VOLUME_RUNS: case BAR_DOLLAR_RUNS: { double buy = InpExpRunsBuyInit; double sell = InpExpRunsSellInit; //--- Fallback to symmetric seed when both are still at the sentinel default if(buy < 0.0 && sell < 0.0) { buy = InpExpImbInit; sell = InpExpImbInit; } ENUM_IMBALANCE_METRIC metric = (InpBarType == BAR_TICK_RUNS) ? IMBALANCE_TICK : (InpBarType == BAR_VOLUME_RUNS) ? IMBALANCE_VOLUME : IMBALANCE_DOLLAR; return new CRunsBar(metric, InpExpTicksInit, buy, sell, InpEwmSpan); } } return NULL; }
Два новых входных параметра — InpExpRunsBuyInit и InpExpRunsSellInit; их значения по умолчанию равны -1. Если оба параметра отрицательны (сторожевое значение по умолчанию), советник использует резервный вариант: задает обоим направлениям начальное значение InpExpImbInit. Это та же симметричная инициализация, которую функция make_bars() на Python использует, когда указан только exp_imbalance_init. Если пользователь задает явные неотрицательные значения для любого из параметров, они используются напрямую. Это позволяет задать асимметричные начальные значения для совпадения с результатами калибровки на Python.

Рис. 3. Трехуровневая схема наследования классов-конструкторов баров MQL5 с добавленным CRunsBar.
- Уровень 1: CBarConstructor (абстрактный класс) содержит общий накопитель OHLC и предоставляет чисто виртуальный метод ProcessTick(), а также интерфейс сохранения состояния SaveState / LoadState.
- Уровень 2: CRunsBar добавлен наряду с CImbalanceBar; оба используют один базовый класс CBarConstructor. Оба элемента обведены оранжевыми рамками, указывающими на дополнительное сохраняемое состояние помимо базового накопителя. Пунктирная стрелка от CRunsBar к CImbalanceBar показывает, что CRunsBar.mqh подключает CImbalanceBars.mqh для совместного использования ENUM_IMBALANCE_METRIC. Это зависимость от объявления, а не наследование.
- Уровень 3: классы CVolumeBar и CDollarBar наследуются от CCumSumBar и отличаются только величиной на тик, которую возвращает TickMetric().
Сохранение состояния и обработка разрывов
Переопределенные методы SaveState() и LoadState() в CRunsBar сначала вызывают реализацию базового класса, сохраняющую накопитель OHLC, счетчики тиков и метаданные последнего тика, а затем записывают или считывают дополнительное состояние бара серий. Помимо состояния, сохраняемого CImbalanceBar, есть девять дополнительных скалярных значений — m_theta_buy, m_theta_sell, m_ewm_T, m_ewm_runs_buy, m_ewm_runs_sell, m_exp_T, m_exp_buy, m_exp_sell и m_bar_tick_count, а также prev_price и prev_sign тикового правила:
bool CRunsBar::SaveState(const int fh) { if(!CBarConstructor::SaveState(fh)) return false; FileWriteDouble(fh, m_theta_buy); FileWriteDouble(fh, m_theta_sell); FileWriteDouble(fh, m_ewm_T); FileWriteDouble(fh, m_ewm_runs_buy); FileWriteDouble(fh, m_ewm_runs_sell); FileWriteDouble(fh, m_exp_T); FileWriteDouble(fh, m_exp_buy); FileWriteDouble(fh, m_exp_sell); FileWriteLong (fh, m_bar_tick_count); FileWriteDouble(fh, m_tick_rule.PrevPrice()); FileWriteDouble(fh, m_tick_rule.PrevSign()); return true; } bool CRunsBar::LoadState(const int fh) { if(!CBarConstructor::LoadState(fh)) return false; m_theta_buy = FileReadDouble(fh); m_theta_sell = FileReadDouble(fh); m_ewm_T = FileReadDouble(fh); m_ewm_runs_buy = FileReadDouble(fh); m_ewm_runs_sell = FileReadDouble(fh); m_exp_T = FileReadDouble(fh); m_exp_buy = FileReadDouble(fh); m_exp_sell = FileReadDouble(fh); m_bar_tick_count = FileReadLong (fh); m_tick_rule.SetPrevPrice(FileReadDouble(fh)); m_tick_rule.SetPrevSign (FileReadDouble(fh)); return true; }
Предыдущую цену и предыдущий знак тикового правила нужно сохранять для баров серий по той же причине, что и для баров дисбаланса. Без них на первом тике после перезапуска советника тиковое правило возвращается к состоянию по умолчанию (+1), и любой нисходящий тик, поступивший сразу после восстановления соединения, классифицируется неверно. Для баров серий это означает, что накопитель продаж, значение которого до перезапуска было близко к порогу, на первом тике после перезапуска искажается ложным вкладом покупок. Ошибка затрагивает не более одного тика, но нарушает совпадение с эталонной реализацией на Python, которая обрабатывает непрерывный поток тиков.
Проверка устаревания в RestoreState(), представленная в предыдущей статье, осталась без изменений. Файл состояния, записанный более чем InpStateMaxAgeMin минут назад, не используется, а конструктор бара повторно инициализируется значениями InpExpTicksInit, InpExpRunsBuyInit и InpExpRunsSellInit. Для баров серий порог устаревания следует задавать консервативно. Часовой разрыв в потоке тиков может существенно изменить эмпирическое соотношение покупок и продаж, если совпадает с границей сессии. Восстановление состояния до разрыва и продолжение работы менее вредны, чем повторная инициализация по потенциально устаревшей JSON-конфигурации, но оба варианта предпочтительнее полного игнорирования разрыва.
Проверка совпадения
Проверка совпадения для баров серий имеет ту же структуру, что и проверка для баров дисбаланса из предыдущей статьи. Советник запускается в Тестере стратегий в режиме Каждый тик на основе реальных тиков на заданном историческом интервале; функция make_bars() на Python обрабатывает тот же поток тиков, экспортированный из хранилища Parquet; оба результата загружаются в Jupyter-ноутбук и соединяются по tick_num:
import pandas as pd from afml.data_structures.bars import make_bars from afml.mt5.clean_data import clean_tick_data tick_df = pd.read_parquet("EURUSD_2024_01_15.parquet") tick_df = clean_tick_data(tick_df) py_bars = make_bars( tick_df, bar_type="dollar_runs", exp_ticks_init=11_700, exp_runs_buy_init=0.020, exp_runs_sell_init=0.016, ) mql_bars = pd.read_csv("EURUSD_dollar_runs.csv", parse_dates=["time"]) merged = py_bars.merge(mql_bars, on="tick_num", suffixes=("_py", "_mql"), how="outer", indicator=True) for col in ["open", "high", "low", "close", "tick_volume"]: diff = (merged[f"{col}_py"] - merged[f"{col}_mql"]).abs() assert diff.max() < 1e-8, f"Mismatch in {col}: max diff {diff.max()}" print(f"Parity verified across {len(merged)} bars")
В ходе разработки были обнаружены три характерных для баров серий несовпадения результатов; они описаны здесь, чтобы их не пришлось выявлять заново.
Первое — несоответствие распределения вкладов между накопителями покупок и продаж. Ядро Python направляет неотрицательные значения величины с учетом знака в θ+, а отрицательные — в θ-. В ранней версии CRunsBar::ProcessTick() для отнесения к покупкам использовалось условие sign > 0 (строго положительное), из-за чего тики с нулевым знаком относились к продажам. Решение — использовать metric >= 0.0 в условии MQL5, что соответствует ветви if v >= 0.0 в ядре Python. На практике тики с нулевым знаком встречаются редко, но вызванная ими ошибка на единицу в накопителе накапливается за длительную сессию и однозначно нарушает совпадение результатов.
Второе — несоответствие единиц измерения порога для долларовых баров серий. Калибратор на Python возвращает exp_runs_buy_init и exp_runs_sell_init в долларах (цена × объем на тик для покупок и продаж соответственно). Входные параметры советника InpExpRunsBuyInit и InpExpRunsSellInit нужно задавать в тех же единицах измерения. Передача безразмерной доли (например, результата калибровки тиковых баров серий 0,55) вместо значения в долларах занижает порог на много порядков, из-за чего бар закрывается на каждом тике.
Третье — асимметрия начальных значений EWM. Если exp_imbalance_init передан как единственное начальное значение (упрощенная форма), в реализации на Python для E0[θ+/T] и E0[θ-/T] задается одно и то же начальное значение. Резервный вариант со сторожевым значением по умолчанию в советнике MQL5 (описанный в разделе 5) обеспечивает одинаковое поведение: если пользователь оставляет InpExpRunsBuyInit и InpExpRunsSellInit со значением по умолчанию -1, вместо обоих используется InpExpImbInit. Если пользователь задает явные значения, они должны точно совпадать с результатами калибровки на Python; любая асимметрия между реализациями на нулевом баре вызовет расхождение, которое сохранится до повторного установления равновесия EWM.

Рис. 4. Иллюстрация из двух панелей, показывающая проверку совпадения для долларовых баров серий на фрагменте из 1 200 тиков.
- Панель (a) — цена Bid с наложенными границами баров из Python (синие) и MQL5 (оранжевые пунктирные линии). При точном совпадении оранжевые штрихи видны внутри каждой синей линии. В пилообразном режиме на центральном участке (тики 400-800) бары располагаются реже, а в направленных фазах по обе стороны от него — плотнее, что соответствует ожидаемому поведению баров серий.
- Панель (b) — абсолютная разница значений tick_num между двумя реализациями для каждого бара; во всех случаях указано нулевое значение. Любое ненулевое значение указывало бы на расхождение, которое нужно исследовать перед внедрением.
Практические рекомендации и эвристики выбора
Теперь, когда все десять типов баров реализованы на обеих платформах, на вопрос о выборе можно дать более полный ответ, чем в эвристической таблице из предыдущей статьи. В таблице ниже предыдущие рекомендации дополнены рекомендациями по барам серий:
| Условие | Рекомендуемый тип |
|---|---|
| Стандартный вариант общего назначения, ликвидные валютные пары или акции | Долларовые бары |
| Исследования потока ордеров, рынки с фрагментированным исполнением ордеров | Тиковые бары |
| Товарные инструменты или фьючерсы со стабильными размерами лотов | Объемные бары |
| Выявление режимов, проверка чистого направленного сигнала | Тиковые или долларовые бары дисбаланса |
| Обнаружение всплесков активности одного направления, охват ночных сессий | Тиковые или долларовые бары серий |
| Стратегии возврата к среднему, требующие стабильной плотности баров | Долларовые бары серий |
| Сравнение с базовым вариантом, удобочитаемая отчетность | Временные бары (с фильтром нулевого тикового объема) |
Бары серий не всегда лучше баров дисбаланса, и наоборот. Эти два типа отражают разные вопросы о потоке тиков. Бары дисбаланса отвечают на вопрос: "Сформировал ли рынок чистое направленное движение?" Бары серий отвечают на вопрос: "Возник ли необычно большой объем активности одного направления независимо от активности противоположного?" Оба вопроса имеют смысл. Ответ на первый вопрос полезнее для построения сигналов следования за трендом; ответ на второй — для выявления институционального потока одного направления, который может быть частично хеджирован и поэтому незаметен для накопителя дисбаланса.
Для первой реализации рекомендуемый порядок действий тот же, что и в предыдущей статье: выполнить офлайн-калибровку на Python по полной истории тиков, сохранить начальные значения в конфигурационном JSON-файле, проверить число баров и нормальность распределения доходностей, затем внедрить реализацию. Не пытайтесь независимо настраивать ewm_span для баров серий и баров дисбаланса на одном наборе данных — оба типа используют один параметр span, а их количества баров напрямую несопоставимы. Выберите один тип как основную схему сэмплирования, а другой используйте для диагностической перекрестной проверки.
Заключение
Бары серий завершают таксономию из десяти типов баров, приведенную в главе 1 книги "Advances in Financial Machine Learning". В существующий модуль afml.data_structures.bars добавлены три строковых идентификатора типов баров — tick_runs, volume_runs и dollar_runs, — доступные через одну и ту же точку входа make_bars(). В библиотеку MQL5 добавлен класс CRunsBar, потиково воспроизводящий логику построения на Python в советнике, работающем в реальном времени.
Три детали реализации отличают правильный конструктор баров серий от конструктора с едва заметной ошибкой. Первая — правило распределения по накопителям: неотрицательные значения величины с учетом знака, включая ноль, должны поступать в накопитель покупок, что соответствует ветви if v >= 0.0 в ядре Python. Вторая — согласованность единиц калибровки: начальные значения для покупок и продаж в объемных и долларовых барах серий имеют размерность (объем и валюта соответственно) и должны определяться и задаваться в этих единицах, а не как безразмерные доли. Третья — симметричная инициализация: когда вместо отдельных начальных значений для покупок и продаж передается одно значение, оба направления в обеих реализациях должны инициализироваться одинаково. Резервный вариант со сторожевым значением по умолчанию в советнике MQL5 гарантирует эту симметрию и предотвращает расхождение на нулевом баре.
Одно необходимое условие вытекает из предыдущей статьи: класс CTickRule в файле CImbalanceBars.mqh должен предоставлять сеттеры SetPrevPrice() и SetPrevSign(), чтобы CRunsBar::LoadState() мог восстановить состояние тикового правила при перезапуске. Без этих сеттеров первый тик после перезапуска советника классифицируется неверно, а в накопителях покупок и продаж возникает ограниченная, но систематическая ошибка, нарушающая совпадение с эталонной реализацией на Python.
Проверка совпадения подтверждает нулевую разницу значений tick_num по всем барам между двумя реализациями при обработке одного потока тиков с согласованной инициализацией. Это совпадение — минимальное требование для внедрения советника MQL5 с уверенностью, что его последовательность баров та же, которая использовалась при обучении в пайплайне на Python.
Прикрепленные файлы
Перед компиляцией скопируйте обновленную папку AlternativeBars в каталог MQL5/Include/, а обновленный файл BarBuilderEA.mq5 — в каталог MQL5/Experts/. Файлы Python заменяют соответствующие модули в каталоге afml/data_structures/.
| Файл | Описание |
|---|---|
| MQL5 | |
| AlternativeBars\CBarConstructor.mqh | Без изменений по сравнению с предыдущей статьей: абстрактный базовый класс с общим накопителем OHLC, структурой SBar для передачи бара (включая mid_open/mid_close) и интерфейсом сохранения состояния SaveState/LoadState |
| AlternativeBars\CStandardBars.mqh | Без изменений: CTimeBar, CTickBar, CCumSumBar, CVolumeBar и CDollarBar |
| AlternativeBars\CImbalanceBars.mqh | Обновлено: CTickRule теперь предоставляет сеттеры SetPrevPrice() и SetPrevSign(), необходимые для восстановления состояния CRunsBar; ENUM_IMBALANCE_METRIC переименован в IMBALANCE_TICK/_VOLUME/_DOLLAR и используется совместно с CRunsBar |
| AlternativeBars\CRunsBar.mqh | Новое: CRunsBar с выбором метрики через ENUM_IMBALANCE_METRIC (тиковая, объемная, долларовая), адаптивным порогом на основе EWM двух накопителей, диагностическими методами доступа (ThetaBuy, ThetaSell, CurrentThreshold, ExpBuy, ExpSell) и полным сохранением всех девяти скалярных значений баров серий и состояния тикового правила |
| BarBuilderEA.mq5 | Обновлено: фабрика создает экземпляры десяти типов баров через switch по перечислению; добавлены входные параметры InpExpRunsBuyInit и InpExpRunsSellInit со сторожевым значением по умолчанию и резервным использованием InpExpImbInit для симметричной инициализации; динамически задаются имена выходных CSV-файлов (Symbol_bar_type_params.csv); сохранена обратная совместимость со всеми семью типами баров из предыдущей статьи |
| Python — afml/data_structures/ | |
| afml/data_structures/bars.py | Обновлено: диспетчер make_bars() расширен типами tick_runs, volume_runs и dollar_runs; exp_imbalance_init используется для симметричной инициализации, когда exp_runs_buy_init/_sell_init не заданы; при калибровке типы баров серий и дисбаланса различаются автоматически |
| afml/data_structures/information_bars.py | Обновлено: JIT-ядро _detect_runs_boundaries() с рекуррентным вычислением EWM для двух накопителей; общий с барами дисбаланса этап агрегирования через np.add.reduceat |
| afml/data_structures/calibration.py | Обновлено: _calibrate_runs_bar_params() определяет E0[T] по медианной плотности тиков, E0[θ+/T] — по средней величине для покупок, а E0[θ-/T] — по средней величине для продаж с масштабированием к нужным единицам для объемных и долларовых вариантов; возвращает кортеж из трех чисел типа float; нижняя граница 1e-6 предотвращает снижение порога до нуля на выборках с тиками только одного направления |
Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/22213
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Создание объектно-ориентированного движка инференса ONNX на языке MQL5
От начального к среднему уровню: Файл шаблона графика (I)
Нейронная сеть на практике: Начинаем строить цепочку
Нейронная сеть на практике: Вопрос шкалы
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования
Отличный выбор для слогана статьи ;)
(За пределами GARCH… За пределами «Часов»)
Отличный выбор для слогана статьи ;)
(За пределами GARCH… За пределами «Часов»)