Beyond the Clock (Part 2): Building Runs Bars in MQL5
Table of Contents
- Introduction
- Recap: What Runs Bars Are and Why They Exist
- Python Implementation: make_bars()
- Calibration and the Initialization Problem
- Runs Bars vs Imbalance Bars: Statistical Comparison
- MQL5 Implementation: CRunsBar
- State Persistence and Gap Handling
- Parity Verification
- Practical Guidance and Selection Heuristics
- Conclusion
- 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.

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.

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.

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 |
Warning: All rights to these materials are reserved by MetaQuotes Ltd. Copying or reprinting of these materials in whole or in part is prohibited.
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.
MQL5 Trading Tools (Part 33): Building a Rich Content Markup Documentation System for MQL5 Programs
Application of the Grey Model in Technical Analysis of Financial Time Series
Building Volatility Models in MQL5 (Part III): Implementing the SLSQP Algorithm for Model Estimation
Keeping Memory Across Restarts: EA State Persistence Using Binary Files in MQL5
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use
Nice choice for the article tagline ;)
(Beyond GARCH .... Beyond The Clock)
Nice choice for the article tagline ;)
(Beyond GARCH .... Beyond The Clock)