preview
Beyond the Clock (Part 2): Building Runs Bars in MQL5

Beyond the Clock (Part 2): Building Runs Bars in MQL5

MetaTrader 5Trading systems |
966 2
Patrick Murimi Njoroge
Patrick Murimi Njoroge

Table of Contents

  1. Introduction
  2. Recap: What Runs Bars Are and Why They Exist
  3. Python Implementation: make_bars()
  4. Calibration and the Initialization Problem
  5. Runs Bars vs Imbalance Bars: Statistical Comparison
  6. MQL5 Implementation: CRunsBar
  7. State Persistence and Gap Handling
  8. Parity Verification
  9. Practical Guidance and Selection Heuristics
  10. Conclusion
  11. Attached Files


Introduction

The previous article implemented all ten bar types from Chapter 1 of López de Prado's Advances in Financial Machine Learning, including tick, volume, and dollar imbalance bars. One family was left with a briefer treatment than the others: runs bars. This article completes the picture.

Runs bars are a close relative of imbalance bars. An imbalance bar closes when the cumulative signed metric — summing over all ticks, each weighted by its direction — exceeds a threshold. A runs bar closes when the cumulative metric computed from ticks of the dominant side alone exceeds a threshold. The distinction matters: imbalance bars respond to net directional pressure, which can be masked when buy and sell volume are large and roughly equal; runs bars respond to the raw magnitude of whichever side is currently dominant, making them sensitive to bursts of one-sided activity even when the opposing side is also active.

The implementation follows the same architecture as the previous article: Python extends afml.data_structures with three runs-bar types behind make_bars(), while MQL5 adds CRunsBar with tick-by-tick logic and the same persistence interface. Parity checks confirm identical bar sequences given the same tick stream.

This article addresses four implementation details. First, it explains how the run and imbalance recurrences differ and why that matters in code. Second, it covers runs-bar initialization and extends target_timeframe calibration accordingly. Third, it specifies the additional state required for persistence across EA restarts. Fourth, it outlines when runs bars differ materially from imbalance bars.


Recap: What Runs Bars Are and Why They Exist

Imbalance bars close when the net signed flow exceeds a threshold. If, in a 200-tick window, 110 ticks are buyer-initiated and 90 are seller-initiated, the cumulative imbalance θT is 110 − 90 = 20. The bar closes when |θT| ≥ threshold. Runs bars use a different accumulator. Instead of netting buy and sell contributions, they track the two sides separately and close when the larger of the two running totals exceeds a threshold:

θ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])

In the example above, θT+ = 110 and θT = 90. The runs bar threshold applies to max(110, 90) = 110. The imbalance bar threshold applies to |110 − 90| = 20. All else equal, the runs bar threshold requires a larger absolute value to trigger a close, but the accumulator grows faster because it is not reduced by opposing-side flow. Whether this produces more or fewer bars than imbalance bars depends on the market regime.

The practical consequence is behavioral rather than strictly statistical. In a choppy, mean-reverting regime, buy and sell flows alternate rapidly and the imbalance θT stays near zero for extended periods, generating very few bars. The runs accumulators θT+ and θT grow steadily regardless of alternation, so runs bars continue to close at a more regular rate. In a strongly trending regime, both bar types close frequently, but the runs bar responds to the dominant-side magnitude rather than the net, making it less sensitive to noise on the opposing side. The two bar types carry complementary information and are both included in the afml.data_structures.bars library for this reason.

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

Figure 1. 2-panel illustration of accumulator path behavior on a 600-tick stream with three regimes

  • Panel (a): the imbalance accumulator θT (blue) during the choppy central regime (highlighted). Strict alternation of buy and sell ticks keeps θT near zero throughout, producing zero bar closes in that regime.
  • Panel (b): the dominant-side run accumulator max(θ+, θ) (orange). Both sides accumulate regardless of alternation; the dominant side reaches the threshold three times during the same choppy window that the imbalance bar misses entirely. Green vertical lines mark all bar closes; the three choppy-regime run closes are annotated.


Python Implementation: make_bars()

The runs bar boundary detector follows the same Numba JIT pattern as _detect_imbalance_boundaries(). The accumulator state expands from one scalar (θ) to three: θT+, θT, and the EWM estimates for both sides' expected per-tick contributions. The EWM update at each bar close requires two separate recurrences rather than one:

@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]

The signed metric passed to this function is identical to the one used by the corresponding imbalance bar type: bt for tick runs bars, bt · vt for volume runs bars, and bt · pt · vt for dollar runs bars. The difference is entirely in how the accumulator is updated: imbalance bars add the signed metric directly to θ, runs bars route positive values to θT+ and the absolute value of negative entries to θT. This means the tick-rule kernel is shared without modification; only the boundary detector changes.

The aggregation step after boundary detection is unchanged from the imbalance bar path. np.add.reduceat() aggregates the tick array at the detected boundary indices, producing OHLC prices, volume sums, and spread means in a single vectorized pass. The resulting DataFrame has the same column schema as all other bar types returned by make_bars().

Three new bar type strings are registered in the make_bars() dispatcher:

# 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,
)

The exp_imbalance_init parameter is reused for the runs bar case and, when supplied without dedicated per‑side values, is interpreted symmetrically: both E0+/T] and E0/T] are seeded at the same value. This is a simplification; in live markets the buy and sell intensities are rarely exactly equal. The calibration path described in the next section derives them separately, and the explicit‑seed form supports supplying those separate values directly via exp_runs_buy_init and exp_runs_sell_init.


Calibration and the Initialization Problem

The imbalance bar calibrator in _calibrate_information_bar_params() derives a single initial imbalance estimate |E0[bt]| from the signed metric. Runs bars require two estimates — E0+/T] and E0/T] — derived from the buy-side and sell-side metric distributions separately. The calibration function returns a tuple of three floats:

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

The floor at 1e-6 prevents the threshold from collapsing to zero on a perfectly one-sided initial sample. In practice, any tick dataset long enough to anchor E0[T] reliably via median tick density will also contain enough two-sided flow to produce non-degenerate buy and sell estimates. The floor is a safety valve for synthetic test data and very short histories.

A subtle difference from the imbalance calibration: the imbalance path estimates |E0[bt]| as the empirical proportion of ticks with sign +1 minus the proportion with sign −1, then floors it at 0.01. The run calibration uses the mean of the buy-side metric values and the mean of the sell-side metric values separately, without netting. For tick runs bars, where the metric is bt ∈ {+1, −1}, this reduces to the proportion of up-ticks and the proportion of down-ticks respectively — complementary values that sum to 1.0. For volume and dollar runs bars the means carry units (volume units and currency units), and the threshold correctly inherits those units.

The same offline-calibration recommendation from the previous article applies here. Runs Python calibration against the full multi-year Parquet dataset, write exp_ticks_init, exp_runs_buy_init, and exp_runs_sell_init to a JSON configuration file, and supply those values explicitly to the MQL5 EA. Do not rely on CopyTicksRange() in OnInit() to calibrate runs bars from the terminal's local tick cache: the shallow history biases E0[T] toward recent market conditions and may underestimate the sell-side mean if the cached window happens to start mid-trend.


Runs Bars vs Imbalance Bars: Statistical Comparison

Whether to use runs bars or imbalance bars for a given application is not primarily a theoretical question — both are grounded in the same directional-flow literature — but an empirical one. Three diagnostics are worth computing before committing to either type for a production pipeline.

Bar Count Stability Across Regimes

Plot bar count per hour over a multi-day window. Imbalance bars produce very few bars during low-volatility, mean-reverting periods because the net θ stays near zero; runs bars continue to close because neither θ+ nor θ is reduced by opposing flow. If the downstream model requires a minimum observation density — for example, a volatility estimator that needs at least five bars per hour to be reliable — runs bars are the safer choice during overnight sessions.

Return Distribution Normality

Apply the Jarque-Bera test to bar-close-to-bar-close returns. Neither run nor imbalance bars produce perfectly Gaussian returns, but both should outperform time bars. In trending markets the two types produce similar distributions. In choppy markets, imbalance bars produce a heavy-tailed return distribution because very long bars (formed during low-imbalance periods) are mixed with very short bars (formed during brief directional bursts); runs bars produce a more homogeneous bar length distribution and correspondingly more stable return variance.

Serial Autocorrelation

Compute the first-order autocorrelation of bar returns. Activity-based bars are designed to reduce serial correlation relative to time bars, but the degree of reduction varies by regime. In liquid FX markets, dollar runs bars typically reduce first-order autocorrelation by 40–60% compared to one-minute time bars. Dollar imbalance bars reduce it by 50–70% over the same period. The difference narrows during trending sessions. Neither type eliminates autocorrelation entirely, because the tick rule is a noisy proxy for true trade direction.


Figure 2. 3-panel illustration of dollar runs bars vs dollar imbalance bars on a 5-day tick stream with embedded overnight sessions

  • Panel (a): hourly bar count. Runs bars (orange) maintain a bar count above the 3-per-hour reference line throughout each overnight window (highlighted); imbalance bars (blue) drop to fewer than 1 per hour during the same periods.
  • Panel (b): QQ-plot of bar-close returns. Both activity-based bar types track the normal reference line more closely than time bars (grey). Runs bar quantiles (orange) deviate less in the tails during the full 5-day window because the more consistent bar length produces more homogeneous return variance.
  • Panel (c): rolling first-order autocorrelation with a 40-bar window. Autocorrelation for both types oscillates near zero; runs bars exhibit slightly lower variance in the autocorrelation estimate, consistent with more stable bar lengths.


MQL5 Implementation: CRunsBar

CRunsBar derives from CBarConstructor in the same way as CImbalanceBar, inheriting the shared OHLC accumulator, the SBar emission struct, and the SaveState/LoadState interface. The class adds three accumulators beyond what CImbalanceBar carries: m_theta_buy, m_theta_sell, and a separate EWM scalar for each side's expected per-tick contribution.

One prerequisite change to CImbalanceBars.mqh is required before CRunsBar can be built: the CTickRule class needs public setters so that CRunsBar::LoadState() can restore the tick rule's prev_price and prev_sign on restart. The previous article's CTickRule exposed only read-only accessors. Two setters are added:

//--- In CImbalanceBars.mqh — addition to CTickRule
void SetPrevPrice(const double p) { m_prev_price = p; }
void SetPrevSign (const double s) { m_prev_sign  = s; }

With those setters in place, CRunsBar is declared as follows:

//--- 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; }
  };

The ProcessTick() method follows the same three-helper sequence used by CImbalanceBar, with the accumulator update split across two conditionals on the tick direction:

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;
  }

One design choice deserves explanation. SeedBar() is called after the EWM update and the accumulator reset, not before. This means the bar boundary tick — the tick that caused the close — becomes the first tick of the new bar rather than the last tick of the closing bar. This follows the Python convention in _detect_runs_boundaries() where bar_start = t + 1 is set after writing the boundary at index t, and preserves the imbalance-bar semantics from the previous article. The closed bar's OHLC already includes the boundary tick via UpdateAccumulator(), which was called at the top of ProcessTick() before the close check. This is the same ordering used by CImbalanceBar.

The TickMetric() helper is a private method that returns the signed metric for the current tick, selected by m_metric_type. It reuses the ENUM_IMBALANCE_METRIC enum defined in CImbalanceBars.mqh, which avoids duplicating the three-variant metric selector in both files. CRunsBar.mqh includes CImbalanceBars.mqh rather than re-declaring the enum:

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;
     }
  }

The EA factory in BarBuilderEA.mq5 is extended with three new cases in the switch statement. The bar type enum now includes the run variants. The factory also includes a fallback for symmetric seeding: when both InpExpRunsBuyInit and InpExpRunsSellInit are left at their default of −1, they are replaced by InpExpImbInit, matching the simplified initialization used in Python when only exp_imbalance_init is supplied.

//--- 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;
  }

The two new input parameters are InpExpRunsBuyInit and InpExpRunsSellInit, defaulting to −1. When both are negative (the sentinel default), the EA falls back to seeding both sides at InpExpImbInit, which is the same symmetric initialization used by the Python make_bars() when only exp_imbalance_init is provided. If the user supplies explicit, non‑negative values for either parameter, those are used directly, enabling asymmetric seeds for parity with the Python calibration output.

Updated UML class diagram including CRunsBar

Figure 3. 3-level inheritance diagram of the MQL5 bar-constructor classes with CRunsBar added

  • Level 1: CBarConstructor (abstract) owns the shared OHLC accumulator and exposes the pure-virtual ProcessTick() plus the SaveState / LoadState persistence interface.
  • Level 2: CRunsBar is added alongside CImbalanceBar, sharing the same CBarConstructor base. Both carry orange borders indicating additional persisted state beyond the base accumulator. The dashed arrow from CRunsBar to CImbalanceBar indicates that CRunsBar.mqh includes CImbalanceBars.mqh to share the ENUM_IMBALANCE_METRIC selector — this is a symbol dependency, not inheritance.
  • Level 3: CVolumeBar and CDollarBar derive from CCumSumBar, differing only in the per-tick metric returned by TickMetric().


State Persistence and Gap Handling

The SaveState() and LoadState() overrides in CRunsBar chain the base class implementation — which persists the OHLC accumulator, tick counters, and last tick metadata — and then write or read the additional run-bar state. There are nine additional scalars beyond what CImbalanceBar persists: 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, and m_bar_tick_count, plus the tick rule's prev_price and 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;
  }

The tick rule's previous price and previous sign must be persisted for runs bars for the same reason they must be persisted for imbalance bars: without them, the tick rule resets to its default state (+1) on the first tick after an EA restart, which misclassifies any down-tick that arrives immediately after reconnection. For runs bars the consequence is that a sell-side accumulator that was near threshold before the restart gets contaminated with a false buy contribution on the first post-restart tick. The error is bounded — it affects at most one tick — but it breaks parity with the Python baseline, which sees a continuous tick stream.

The staleness check in RestoreState(), introduced in the previous article, is unchanged. A state file written more than InpStateMaxAgeMin minutes ago is discarded, and the bar constructor is re-initialized from InpExpTicksInit, InpExpRunsBuyInit, and InpExpRunsSellInit. For runs bars the staleness threshold should be set conservatively. A one-hour gap in tick flow can shift the empirical buy/sell ratio substantially if it coincides with a session boundary; restoring state from before that gap and continuing is less harmful than re-initializing from a potentially stale JSON config, but both are preferable to ignoring the gap entirely.


Parity Verification

The parity test for runs bars follows the same structure as the imbalance bar test in the previous article. The EA is run in Strategy Tester with Every tick based on real ticks mode over a defined historical window; the Python make_bars() call processes the same tick stream exported from the Parquet store; both outputs are loaded in a notebook and merged on 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")

Three parity failures specific to runs bars were encountered during development and are documented here so they do not have to be rediscovered.

The first is a buy/sell routing mismatch. The Python kernel routes non-negative signed metric values to θ+ and negative values to θ. An early version of CRunsBar::ProcessTick() used sign > 0 (strictly positive) for the buy route, assigning zero-sign ticks to the sell side. The fix is to use metric >= 0.0 in the MQL5 conditional, matching the Python kernel's if v >= 0.0 branch. Zero-sign ticks are rare in practice but the off-by-one they cause in the accumulator accumulates across a long session and breaks parity conclusively.

The second is a threshold unit mismatch for dollar runs bars. The Python calibrator returns exp_runs_buy_init and exp_runs_sell_init in dollar units (price × volume per tick on the buy and sell sides respectively). The EA input parameters InpExpRunsBuyInit and InpExpRunsSellInit must be supplied in the same units. Supplying a dimensionless proportion (for example, the tick-runs calibration result of 0.55) in place of a dollar-unit value causes the threshold to be set many orders of magnitude too low, closing a bar on every tick.

The third is an EWM seed asymmetry. When exp_imbalance_init is supplied as the sole initializer (the simplified form), the Python side seeds both E0+/T] and E0/T] at the same value. The MQL5 EA’s sentinel‑default fallback (described in §5) ensures identical behaviour: if the user leaves InpExpRunsBuyInit and InpExpRunsSellInit at their default of −1, both are replaced by InpExpImbInit. If the user supplies explicit values, they must match the Python calibration output exactly; any asymmetry between the two implementations at bar zero will cause divergence that persists until the EWMs re‑equilibrate.

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

Figure 4. 2-panel illustration of parity verification for dollar runs bars on a 1,200-tick slice

  • Panel (a): bid price with Python bar boundaries (blue) and MQL5 boundaries (orange dashed) overlaid — exact agreement shows as orange dashes inside every blue line. Bars are sparser in the choppy mid-regime (ticks 400–800) and denser in the directional phases on each side, which is the expected runs bar behavior.
  • Panel (b): per-bar absolute difference in tick_num between the two implementations across all bars, annotated zero across the board. Any non-zero entry would indicate a divergence requiring investigation before deployment.


Practical Guidance and Selection Heuristics

With all ten bar types now implemented across both platforms, the selection question has a fuller answer than the heuristic table in the previous article. The table below extends that table to include runs bar recommendations:

Condition Recommended type
General-purpose default, liquid FX or equity Dollar bars
Order-flow studies, fragmented execution markets Tick bars
Commodity or futures with stable lot sizes Volume bars
Regime detection, net directional signal validation Tick or dollar imbalance bars
One-sided burst detection, overnight session coverage Tick or dollar runs bars
Mean-reversion strategies requiring consistent bar density Dollar runs bars
Baseline comparison, human-readable reporting Time bars (with zero-tick filter)

Runs bars are not a strict improvement over imbalance bars or vice versa. The two types encode different questions about the tick stream. Imbalance bars ask: has the market committed to a net direction? Runs bars ask: has one side of the market generated an unusual volume of activity, regardless of what the other side is doing? Both questions are meaningful. Answering the first is more useful for trend-following signal construction; answering the second is more useful for detecting one-sided institutional flow, which may be partially hedged and therefore invisible to the imbalance accumulator.

For a first implementation, the recommended path is identical to the one in the previous article: calibrate offline using Python against the full tick history, store the resulting seeds in a JSON configuration file, validate bar count and return distribution normality, then deploy. Do not attempt to tune ewm_span independently for run and imbalance bars on the same dataset — the two types share the same span parameter and their bar counts are not directly comparable. Choose one type as the primary sampling scheme and use the other as a diagnostic cross-check.


Conclusion

Runs bars complete the ten-bar taxonomy from Chapter 1 of Advances in Financial Machine Learning. The implementation extends the existing afml.data_structures.bars module with three new bar type strings — tick_runs, volume_runs, and dollar_runs — behind the same make_bars() entry point, and adds a CRunsBar class to the MQL5 library that mirrors the Python construction logic tick-by-tick in a live EA.

Three implementation details separate a correct runs bar constructor from a subtly wrong one. The first is the accumulator routing convention: non-negative signed metric values must go to the buy accumulator, including zero, matching the Python kernel's if v >= 0.0 branch. The second is the calibration unit consistency: the buy and sell initial seeds for volume and dollar runs bars carry units (volume and currency respectively) and must be derived from and supplied in those units, not as dimensionless proportions. The third is symmetric seed initialization: when a single initializer is supplied rather than separate buy and sell seeds, both sides must be seeded at the same value in both implementations; the MQL5 EA’s sentinel‑default fallback guarantees this symmetry, preventing divergence at bar zero.

One prerequisite that flows from the previous article: the CTickRule class in CImbalanceBars.mqh must expose SetPrevPrice() and SetPrevSign() setters so that CRunsBar::LoadState() can restore tick rule state on restart. Without those setters, the first tick after an EA restart is misclassified, and the buy and sell accumulators accumulate a bounded but systematic error that breaks parity with the Python baseline.

The parity test confirms zero difference in tick_num across all bars for both implementations when fed the same tick stream with consistent initialization. That agreement is the minimum requirement for deploying the MQL5 EA with confidence that its bar sequence is the same as the one the Python pipeline was trained on.


Attached Files

Copy the updated AlternativeBars folder to MQL5/Include/ and the updated BarBuilderEA.mq5 to MQL5/Experts/ before compiling. The Python files replace the corresponding modules in afml/data_structures/.

File Description
MQL5
AlternativeBars\CBarConstructor.mqh Unchanged from the previous article: abstract base class with shared OHLC accumulator, SBar emission struct (including mid_open/mid_close), and SaveState/LoadState persistence interface
AlternativeBars\CStandardBars.mqh Unchanged: CTimeBar, CTickBar, CCumSumBar, CVolumeBar, and CDollarBar
AlternativeBars\CImbalanceBars.mqh Updated: CTickRule now exposes SetPrevPrice() and SetPrevSign() setters required by CRunsBar state restoration; ENUM_IMBALANCE_METRIC renamed to IMBALANCE_TICK/_VOLUME/_DOLLAR and shared with CRunsBar
AlternativeBars\CRunsBar.mqh New: CRunsBar with ENUM_IMBALANCE_METRIC selector (tick, volume, dollar), dual-accumulator EWM-adaptive threshold, diagnostic accessors (ThetaBuy, ThetaSell, CurrentThreshold, ExpBuy, ExpSell), and full state persistence for all nine run-bar scalars plus tick rule state
BarBuilderEA.mq5 Updated: factory instantiation extended to ten bar types via enum switch; two new input parameters InpExpRunsBuyInit and InpExpRunsSellInit with sentinel‑default fallback to InpExpImbInit for symmetric seeding; dynamic CSV output file naming (Symbol_bar_type_params.csv); backward‑compatible with all seven bar types from the previous article
Python — afml/data_structures/
afml/data_structures/bars.py Updated: make_bars() dispatcher extended with tick_runs, volume_runs, and dollar_runs; exp_imbalance_init used as symmetric fallback when exp_runs_buy_init/_sell_init are omitted; calibration path discriminates between run and imbalance types automatically
afml/data_structures/information_bars.py Updated: _detect_runs_boundaries() JIT kernel with dual-accumulator EWM recurrence; aggregation path shared with imbalance bars via np.add.reduceat
afml/data_structures/calibration.py Updated: _calibrate_runs_bar_params() derives E0[T] from median tick density, E0+/T] from the mean buy-side metric, and E0/T] from the mean sell-side metric, scaled to the correct units for volume and dollar variants; returns a tuple of three floats; floor at 1e-6 prevents threshold collapse on one-sided samples
Attached files |
afml.zip (17.27 KB)
AlternativeBars.zip (14.55 KB)
Last comments | Go to discussion (2)
Muhammad Minhas Qamar
Muhammad Minhas Qamar | 29 May 2026 at 18:12

Nice choice for the article tagline ;) 

(Beyond GARCH .... Beyond The Clock)

Patrick Murimi Njoroge
Patrick Murimi Njoroge | 6 Jun 2026 at 02:38
Muhammad Minhas Qamar #:

Nice choice for the article tagline ;) 

(Beyond GARCH .... Beyond The Clock)

Lol! Great minds...
MQL5 Trading Tools (Part 33): Building a Rich Content Markup Documentation System for MQL5 Programs MQL5 Trading Tools (Part 33): Building a Rich Content Markup Documentation System for MQL5 Programs
We extend the Part 9 setup wizard to build a canvas-based, in-chart documentation system for MetaTrader 5. The panel is tabbed and scrollable, supports inline styling, images, and interactive controls, and renders with supersampled anti-aliasing. The result is a reusable engine that any MQL5 program can embed to deliver self-contained documentation directly on the chart.
Application of the Grey Model in Technical Analysis of Financial Time Series Application of the Grey Model in Technical Analysis of Financial Time Series
This article explores the grey model, a promising tool that can expand trader's capabilities. We will look at some options for applying this model to technical analysis and building trading strategies.
Building Volatility Models in MQL5 (Part III): Implementing the SLSQP Algorithm for Model Estimation Building Volatility Models in MQL5 (Part III): Implementing the SLSQP Algorithm for Model Estimation
An SLSQP optimizer is implemented in MQL5 to resolve parameter discrepancies between a volatility library and Python's ARCH module. The article details constraint handling, gradient options, configuration, and convergence controls and shows how to integrate the solver into existing code. Practical examples and comparisons demonstrate matched log‑likelihoods and parameters on shared datasets.
Keeping Memory Across Restarts: EA State Persistence Using Binary Files in MQL5 Keeping Memory Across Restarts: EA State Persistence Using Binary Files in MQL5
This article provides a structured MQL5 framework for serializing an Expert Advisor's internal state into local binary files. It prevents data resets during platform restarts by safely storing volatile tracking metrics, such as trade counts and multipliers, directly to disk. This architecture offers a more robust state continuity alternative to terminal Global Variables.