English
preview
Альтернатива временным барам (Часть 2): Построение баров серий в MQL5

Альтернатива временным барам (Часть 2): Построение баров серий в MQL5

MetaTrader 5 — Торговые системы |
56 2
Patrick Murimi Njoroge
Patrick Murimi Njoroge

Содержание

  1. Введение
  2. Краткий обзор — что такое бары серий и зачем они нужны
  3. Реализация на Python — make_bars()
  4. Калибровка и проблема инициализации
  5. Бары серий и бары дисбаланса — статистическое сравнение
  6. Реализация на MQL5 — CRunsBar
  7. Сохранение состояния и обработка разрывов
  8. Проверка совпадения
  9. Практические рекомендации и эвристики выбора
  10. Заключение
  11. Прикрепленные файлы


Введение

В предыдущей статье были реализованы все десять типов баров из главы 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.

Runs bar vs imbalance bar accumulator paths on the same tick stream

Рис. 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.

Updated UML class diagram including CRunsBar

Рис. 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.

Python and MQL5 dollar-runs-bar boundary agreement on a 1,200-tick slice

Рис. 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

Прикрепленные файлы |
afml.zip (17.27 KB)
AlternativeBars.zip (14.55 KB)
Последние комментарии | Перейти к обсуждению на форуме трейдеров (2)
Muhammad Minhas Qamar
Muhammad Minhas Qamar | 29 мая 2026 в 18:12

Отличный выбор для слогана статьи ;)

(За пределами GARCH… За пределами «Часов»)

Patrick Murimi Njoroge
Patrick Murimi Njoroge | 6 июн. 2026 в 02:38
Muhammad Minhas Qamar #:

Отличный выбор для слогана статьи ;)

(За пределами GARCH… За пределами «Часов»)

Ха-ха! Великие умы...
Создание объектно-ориентированного движка инференса ONNX на языке MQL5 Создание объектно-ориентированного движка инференса ONNX на языке MQL5
В статье рассказывается, как запускать модели, обученные на Python, непосредственно в MetaTrader 5 с помощью функций ONNX терминала. Мы создаем класс MQL5, который инкапсулирует создание сеанса, фиксирует формы входных/выходных тензоров, применяет min-max-нормализацию признаков, чтобы повторять обработку, использованную при обучении, и запускает OnnxRun один раз на каждый бар, чтобы снизить нагрузку на ЦП. В результате получается надежный и удобный в сопровождении канал инференса для графиков в реальном времени и Тестера стратегий без использования сокетов или DLL-библиотек.
От начального к среднему уровню: Файл шаблона графика (I) От начального к среднему уровню: Файл шаблона графика (I)
В статье объясняется, как перенести настройки основного графика на встроенный в OBJ_CHART график, учитывая, что наследуется только часть шаблона. Мы покажем, как синхронизировать цвета и свойства, подстраиваться под изменения размера и применять настройки к внутреннему графику, используя идентификатор графика и явные вызовы обновления. Читатель научится предсказуемо воспроизводить внешний вид и поведение основного графика в OBJ_CHART.
Нейронная сеть на практике: Начинаем строить цепочку Нейронная сеть на практике: Начинаем строить цепочку
Пришло время начать разбираться в том, как соединять перцептроны в цепочку. Это позволит нам реализовать то, что известно как сеть перцептронов. Эта сеть составляет основу всего того, что вы знаете и используете как ту или иную форму «искусственного интеллекта». Однако построение такой сети связано с рядом трудностей. Здесь мы начнём разбираться, в чём они заключаются.
Нейронная сеть на практике: Вопрос шкалы Нейронная сеть на практике: Вопрос шкалы
Среди широкой публики бытует ложное представление о том, что нейронная сеть или искусственный интеллект могут в некотором смысле постигать мир, в котором мы живём. В некоторых случаях это даже может быть правдой. Но вам, если вы хотите добиться более быстрой сходимости нейронной сети или даже отдельного нейрона, нужно понимать, что шкала используемых значений часто влияет на скорость сходимости. Кроме того, в этой статье я покажу кое-что странное, что происходит в нейронных сетях. Это сведёт вас с ума и заставит задуматься о многом в этой теме.