Integration

MetaTrader 5 Machine Learning Blueprint (Part 17): CPCV Backtesting — From Python Model to Tick-Level Evidence - MQL5 Articles

June 4, 202624 min read
RoboForex

[

\ Trade from your iPhone or Android device\

\

You only need an internet connection to use the new powerful MetaTrader 5 Web terminal\

\

Learn more](https://www.mql5.com/ff/go?link=https://trade.metatrader5.com/&a=wtigumvtenarnsocpyfoqnanxrilnbxx&s=ec8c539e52b83881ff2d16eaff6913b25803952eb277cac55f670a102b2edc1f&uid=&ref=https://www.mql5.com/en/articles/21954&id=bfogggabsofabcpxuzmgaibarmaxasdrj&fz_uniq=5097745083969374623)

MetaTrader 5 / Integration

Table of Contents

  1. Introduction
  2. What the Pipeline Exports and What MQL5 Needs
  3. The Export Script: Translating Artifacts to MQL5 Formats
  4. Reproducing the Feature Pipeline in MQL5
  5. ONNX Inference and Calibration in MQL5
  6. Implementing CPCV with the Strategy Tester
  7. Path Reporting and Python Post-processing
  8. Practical Walkthrough
  9. Conclusion
  10. Attached Files

Introduction

The Python pipeline described in Parts 8 through 12 produces a trained model, a fitted probability calibrator, a feature specification, and an events DataFrame. These artifacts answer one question about the model: does it have edge on historical bar-level returns?

They leave a second question unanswered: will that edge survive execution costs? Spread, slippage, commission, and swap are not abstract numbers; they are frictions that erode the theoretical advantage the model captured.

A Sharpe ratio distribution computed from bar-by-bar P&L is a useful diagnostic. One computed from tick-level fills is the evidence base on which a deployment decision should be made.

This article builds the bridge between those two worlds. The pipeline exports its artifacts in Python-native formats (ONNX, pickle, parquet).

MetaTrader 5's Strategy Tester consumes flat files (CSV, JSON). An export script translates between them.

On the MQL5 side, an expert advisor loads the translated artifacts in OnInit(). com/en/articles/21915 "Kelly Criterion, Prop Firm Integration, and CPCV Dynamic Backtesting"), and executes orders on the tick stream.

The Strategy Tester's optimization mode then runs each of the φ[N, k] combinatorial paths as a separate agent, producing tick-accurate equity curves that Python collects and analyzes.

The result is a path Sharpe distribution and PBO audit computed from real tick fills. A deployment decision is based on three numbers from that distribution: the median path Sharpe (is the edge real after costs?), the path Sharpe standard deviation (is the performance stable across temporal configurations?), and the PBO (is strategy selection better than chance?).

This article is Part 17 of the MetaTrader 5 Machine Learning Blueprint series. Part 12 produced the calibrated model whose ONNX export is the primary input here. The Unified Validation Pipeline article defined the CPCV fold structure and PBO computation that this article's Strategy Tester orchestration reproduces.

What the Pipeline Exports and What MQL5 Needs

Pipeline Artifacts

When ModelDevelopmentPipeline.run(export_onnx=True, calibrate=True) completes, _save_all_artifacts() writes the following files to the versioned model directory:

Python ArtifactFormatContents
1.model_*.onnxONNXFull sklearn Pipeline (StandardScaler + classifier), converted via skl2onnx. The scaler is baked into the graph.
2.calibrator_*.joblibjoblibFitted CalibratorCV.calibrator_: an IsotonicRegression or LogisticRegression depending on the method parameter.
3.feature_names_*.pklcloudpickleOrdered list of feature column names, matching the ONNX model's input tensor layout.
4.events_*.parquetparquetTriple-barrier events with t1 (label end time), bin, tW, w. Used for CPCV fold boundary computation.
5.config_*.jsonJSONFull training configuration: symbol, bar type, sizing parameters, HPO settings.

None of these formats is directly consumable by MQL5. The ONNX file is the exception: MetaTrader's native OnnxCreate() loads it directly.

Everything else requires translation. The calibrator must be decomposed into its breakpoint arrays.

Feature specifications must be serialized as flat JSON. CPCV fold assignments must be precomputed and written as per-path CSV masks.

A Critical Constraint: The Scaler Is Baked In

)_. Therefore, the StandardScaler parameters are already part of the ONNX computation graph.

MetaTrader 5 must pass raw feature values directly to OnnxRun(). Applying a manual z-score transformation before inference would double-scale the inputs and corrupt every prediction silently.

The feature specification JSON does export the training-set mean and standard deviation for each feature. These values are included for diagnostic validation — to confirm that the raw values computed in MQL5 match the expected distributional range from the Python pipeline — not for transformation. The BuildFeatureVector() function returns raw values only.

The Implementation Contract

Python handles fold computation, artifact translation, and post-processing of results. MQL5 handles tick-accurate simulation of a single CPCV path per Strategy Tester pass.

This division keeps each side doing what it does best. Python's CombinatorialPurgedCV generates the φ[N, k] path assignments; it understands purging, embargo, and combinatorial recombination.

The Strategy Tester's built-in parallelization runs those assignments concurrently across CPU cores; it understands spread, slippage, swap, and commission.

A central design decision is to precompute the path-to-bar mapping in Python and export one mask file per path. This avoids exporting fold boundaries and reconstructing the combinatorial logic in MQL5. The EA's job reduces to a binary search: is this bar's timestamp in my path's mask file?

Figure 1. 5-stage illustration of the Python-to-MQL5 translation architecture for CPCV backtesting

  • Stage 1: Python pipeline artifacts (model_*.onnx, calibrator_*.joblib, feature_names_*.pkl, events_*.parquet).
  • Stage 2: export_pipeline_artifacts.py translates each artifact to a flat file and precomputes CPCV path masks.
  • Stage 3: MQL5/Files/ml_artifacts/ receives the translated files, including one path_N.csv per combinatorial path.
  • Stage 4: Strategy Tester optimization mode runs CPCVBacktest.mq5 once per path (InpPathIndex 0→4 for N=6, k=2).
  • Stage 5: cpcv_postprocess.py collects per-path equity CSVs, computes the path Sharpe distribution, and runs the PBO audit.

The Export Script: Translating Artifacts to MQL5 Formats

The export script is the single point of translation between the Python pipeline and MetaTrader 5. It loads the model directory using load_from_path(), extracts each artifact, converts it to a flat-file format, and writes the results to the _Common\Files\ml_artifacts_ directory where the EA expects to find them. Files written to _Common\Files_ are accessible to both MQL5 and the Python process on the same machine.

Loading the Model Directory

text
1from pathlib import Path
2import json
3import shutil
4import numpy as np
5import pandas as pd
6from sklearn.isotonic import IsotonicRegression
7from sklearn.linear_model import LogisticRegression
8from afml.production.file_manager import ModelFileManager
9from afml.cross_validation.combinatorial import CombinatorialPurgedCV
10
11MODEL_DIR  = Path("./Models/my_strategy/EURUSD/.../a1b2c3d4")
12MQL5_FILES = Path(r"C:\...\AppData\Roaming\MetaQuotes\Terminal\...\Common\Files")
13OUT_DIR    = MQL5_FILES / "ml_artifacts"
14OUT_DIR.mkdir(parents=True, exist_ok=True)
15(OUT_DIR / "results").mkdir(exist_ok=True)
16
17N_FOLDS = 6
18K_TEST  = 2
19# phi = C(6, 2) * 2 // 6 = 15 * 2 // 6 = 5 paths
20
21mgr  = ModelFileManager()
22arts = mgr.load_from_path(MODEL_DIR)
23
24model         = arts["model"]          # sklearn Pipeline (scaler + classifier)
25calibrator    = arts["calibrator"]     # IsotonicRegression or LogisticRegression
26feature_names = arts["feature_names"]  # ordered list
27events        = arts["events"]         # DataFrame with t1
28config        = arts["config"]         # training config dict

Exporting the Calibrator

The calibrator is either an IsotonicRegression or a LogisticRegression, depending on the method parameter passed to CalibratorCV. Both must be decomposed into their numerical parameters and written as CSV or JSON so that MQL5 can reconstruct the mapping without Python-specific serialization formats.

For isotonic regression, the mapping is a piecewise constant step function defined by two parallel arrays: the x-breakpoints and the y-values. Scikit-learn exposes these as X_thresholds_ and y_thresholds_. For Platt scaling, the mapping is a sigmoid defined by the coefficient and intercept of the fitted LogisticRegression.

text
1if isinstance(calibrator, IsotonicRegression):
2    x_pts = calibrator.X_thresholds_
3    y_pts = calibrator.y_thresholds_
4    pd.DataFrame({"x": x_pts, "y": y_pts}).to_csv(
5        OUT_DIR / "calibrator.csv", index=False
6    )
7    cal_meta = {"method": "isotonic", "n_breakpoints": len(x_pts)}
8elif isinstance(calibrator, LogisticRegression):
9    A = float(calibrator.coef_[0, 0])
10    B = float(calibrator.intercept_[0])
11    cal_meta = {"method": "platt", "A": A, "B": B}
12
13json.dump(cal_meta, open(OUT_DIR / "calibrator_meta.json", "w"), indent=2)

Exporting the Feature Specification

The ONNX model expects features in the exact column order recorded in feature_names. Any deviation in ordering, lookback, or computation type produces predictions that are numerically plausible but semantically wrong: the model applies learned weights for feature A to the value of feature B, with no error signal to flag the mismatch.

The export script writes a JSON file recording each feature's name, index, and the training-set normalization parameters (mean and standard deviation from the fitted preprocessor). These parameters are diagnostic only; the EA does not apply them before calling OnnxRun(). Two fields — type and lookback — are written as placeholders that the practitioner must fill in to match the actual feature engineering logic used during training.

text
1# Extract normalization params from the fitted preprocessor
2preprocessor = model.steps[0][1]   # (name, transformer) → transformer
3has_mean  = hasattr(preprocessor, "mean_")
4has_scale = hasattr(preprocessor, "scale_")
5
6feature_specs = []
7for i, name in enumerate(feature_names):
8    spec = {
9        "name": name,
10        "index": i,
11        "type": "RSI"
12,  # placeholder — edit to match your feature set
13        "lookback": 14, # placeholder — edit to match your feature set
14        "mean": float(preprocessor.mean_[i])  if has_mean  else 0.0,
15        "std": float(preprocessor.scale_[i]) if has_scale else 1.0,
16    }
17    feature_specs.append(spec)
18
19json.dump(feature_specs,
20          open(OUT_DIR / "feature_spec.json", "w"), indent=2)

Generating CPCV Path Masks

This is the most important export step. Each CPCV path must be represented as a set of bar timestamps that constitute that path's test set. The EA loads one mask file per Strategy Tester pass and trades only on bars whose timestamps appear in that file.

The number of reconstructed backtest paths is φ[N, k] = C(N, k) × k // N. For N=6, k=2: C(6, 2)=15 splits, φ = 15 × 2 // 6 = 5 paths.

A common error is to confuse the number of splits (15) with the number of paths (5). get_path_ids()_ returns an (n_splits, k) matrix.

It maps each test fold in each split to a path index. The export script iterates over this structure and collects timestamps per path.

text
1cv = CombinatorialPurgedCV(
2    n_folds=N_FOLDS, n_test_folds=K_TEST,
3    t1=events["t1"], pct_embargo=0.01,
4)
5
6n_paths  = cv.n_test_paths    # 5 for N=6, k=2
7path_ids = cv.get_path_ids()  # shape (n_splits, k)
8X_dummy  = pd.DataFrame(np.zeros((len(events), 1)), index=events.index)
9
10path_bars = {p: [] for p in range(n_paths)}
11
12for split_idx, (_, test_lists) in enumerate(cv.split(X_dummy)):
13    for fold_j, test_idx in enumerate(test_lists):
14        path_id = path_ids[split_idx, fold_j]
15        timestamps = events.index[test_idx]
16        path_bars[path_id].extend(timestamps.tolist())
17
18for path_id, timestamps in path_bars.items():
19    pd.Series(sorted(set(timestamps)), name="timestamp").to_csv(
20        OUT_DIR / f"path_{path_id}.csv", index=False
21    )
22
23meta = {"n_folds": N_FOLDS, "k_test": K_TEST,
24        "n_paths": n_paths, "symbol": config.get("symbol", "")}
25json.dump(meta, open(OUT_DIR / "cpcv_meta.json", "w"), indent=2)
26print(f"Exported {n_paths} path masks — InpPathIndex: 0..{n_paths-1}")

After running this script, the _Common\Files\ml_artifacts_ directory contains: the ONNX model file, calibrator.csv and calibrator_meta.json, feature_spec.json, five path_N.csv files (for N=6, k=2), and cpcv_meta.json.

Reproducing the Feature Pipeline in MQL5

Indicator Handles and the Feature Specification Struct

MQL5's indicator API is handle-based: iRSI(), iATR(), and iMA() each return an integer handle, not a value. Values are retrieved via CopyBuffer() referencing that handle.

The EA creates all required handles once in OnInit() and releases them in OnDeinit(). mqh_ manages this lifecycle.

The feature type enumeration and per-feature specification struct are defined in FeatureEngine.mqh:

text
1//+------------------------------------------------------------------+
2//| ENUM_FEAT_TYPE: indicator type for each feature.                 |
3//| Extend this enum and add a matching case in BuildFeatureVector() |
4//| for any indicator type used in your Python feature engineering.  |
5//+------------------------------------------------------------------+
6enum ENUM_FEAT_TYPE
7  {
8   FEAT_RSI,        // RSI(period)
9   FEAT_ATR_NORM,   // ATR(period) / Close
10   FEAT_LOG_RETURN, // log(Close[1] / Close[1+period])
11   FEAT_MA_RATIO,   // Close / SMA(period) - 1.0
12   FEAT_HIST_VOL,   // rolling std-dev of log-returns over last period bars
13  };
14
15struct SFeatureSpec
16  {
17   string         name;      // column name from Python
18   int            index;     // position in the ONNX input tensor
19   ENUM_FEAT_TYPE type;      // computation type
20   int            lookback;  // indicator window
21   double         mean;      // training-set mean (diagnostic only)
22   double         std_dev;   // training-set std  (diagnostic only)
23  };

Feature Vector Construction

The BuildFeatureVector() function iterates over the spec array, retrieves each indicator value via CopyBuffer(), and writes the result as a raw float into the output array. No z-score transformation is applied; the StandardScaler is part of the ONNX graph.

The bar index argument to all indicator and price functions is 1 (the most recently closed bar). Using bar index 0 introduces look-ahead bias at the tick level because the forming bar's close price is still changing.

text
1//+------------------------------------------------------------------+
2//| BuildFeatureVector: compute raw features for the closed bar.     |
3//|                                                                  |
4//| Returns raw (unscaled) float values.  The StandardScaler is      |
5//| baked into the ONNX graph; passing scaled values would corrupt   |
6//| inference results.                                               |
7//+------------------------------------------------------------------+
8bool BuildFeatureVector(float &features[])
9  {
10   if(!g_handles_valid || g_n_features == 0)
11      return(false);
12   if(ArrayResize(features, g_n_features) < 0)
13      return(false);
14
15   double buf[1];
16   for(int i = 0; i < g_n_features; i++)
17     {
18      double raw = 0.0;
19      switch(g_feat_specs[i].type)
20        {
21         case FEAT_RSI:
22            if(CopyBuffer(g_rsi_handle[i], 0, 1, 1, buf) < 0)
23               return(false);
24            raw = buf[0];
25            break;
26
27         case FEAT_ATR_NORM:
28           {
29            if(CopyBuffer(g_atr_handle[i], 0, 1, 1, buf) < 0)
30               return(false);
31            double close1 = iClose(_Symbol, _Period, 1);
32            raw = (close1 > 0) ? buf[0] / close1 : 0.0;
33            break;
34           }
35
36         case FEAT_LOG_RETURN:
37           {
38            double c1 = iClose(_Symbol, _Period, 1);
39            double c0 = iClose(_Symbol, _Period, g_feat_specs[i].lookback + 1);
40            raw = (c1 > 0 && c0 > 0) ? MathLog(c1 / c0) : 0.0;
41            break;
42           }
43
44         case FEAT_MA_RATIO:
45           {
46            if(CopyBuffer(g_ma_handle[i], 0, 1, 1, buf) < 0)
47               return(false);
48            double close1 = iClose(_Symbol, _Period, 1);
49            raw = (buf[0] > 0) ? (close1 / buf[0]) - 1.0 : 0.0;
50            break;
51           }
52        }
53      features[i] = (float)raw;  // ONNX input is float32
54     }
55   return(true);
56  }

A validation step that should not be skipped: after building the feature vector for the first bar of a backtest, log the raw values and compare them against the Python pipeline's output for the same bar. Off-by-one errors in lookback indexing are the most common source of silent prediction corruption. Comparing the first bar explicitly catches these errors before they propagate across thousands of inference calls.

ONNX Inference and Calibration in MQL5

Loading and Running the ONNX Model

The model is loaded once in OnInit() and reused across all bars. The input tensor shape must match the feature count exactly.

A critical difference from the earlier draft is that the ONNX input must be a 2D tensor of shape (1, n_features), not a 1D array. MQL5's OnnxRun() requires that the array dimensions match the shape set by OnnxSetInputShape().

text
1//+------------------------------------------------------------------+
2//| OnTick: new-bar guard, mask check, inference, sizing, execution. |
3//+------------------------------------------------------------------+
4void OnTick()
5  {
6//--- New-bar guard
7   datetime current = iTime(_Symbol, _Period, 0);
8   if(current == g_last_bar_time)
9      return;
10   g_last_bar_time = current;
11
12//--- Use the most recently CLOSED bar (index 1)
13   datetime bar_time = iTime(_Symbol, _Period, 1);
14   if(!IsTestBar(bar_time))
15      return;
16
17//--- Build raw feature vector (no z-score — scaler is baked into ONNX)
18   float features[];
19   if(!BuildFeatureVector(features))
20      return;
21
22//--- 2D input tensor required by OnnxRun()
23   float input_data[1][FE_MAX_FEATURES];
24   for(int i = 0; i < g_n_features; i++)
25      input_data[0][i] = features[i];
26
27   float output_data[1][2];
28   if(!OnnxRun(g_onnx_handle, ONNX_DEFAULT, input_data, output_data))
29      return;
30
31   double raw_prob = (double)output_data[0][1];  // P(class=1)
32   double cal_prob = ApplyCalibrator(raw_prob);
33
34//--- Signal + Kelly sizing from Parts 10 and 11
35   double signal     = GetSignal(cal_prob, 2);
36   double kelly_m    = KellyMultiplier(cal_prob, InpPayoffRatio, InpKellyFraction);
37   double final_size = signal * kelly_m;
38
39   ExecuteOrder(final_size, bar_time);
40  }

Applying the Calibrator

For isotonic regression, the calibrated value for any raw probability in the interval [x[i], x[i+1]) is y[i] directly. Isotonic regression is piecewise constant, not piecewise linear. The binary search returns the left-segment value at index lo; no interpolation is performed. This matches IsotonicRegression.predict() in scikit-learn exactly.\

\

For Platt scaling, the calibrated value is the two-parameter sigmoid: 1 / (1 + exp(-(A × raw + B))). The parameters A and B are exported in calibrator_meta.json and read at OnInit().\

\

cpp
1//+------------------------------------------------------------------+\
2//| ApplyCalibrator: piecewise constant lookup or sigmoid.           |\
3//+------------------------------------------------------------------+\
4double ApplyCalibrator(double raw_prob)\
5  {\
6   if(g_cal_method == CAL_METHOD_ISOTONIC)\
7     {\
8      int n = ArraySize(g_cal_x);\
9      if(n == 0)              return(raw_prob);\
10      if(raw_prob <= g_cal_x[0])     return(g_cal_y[0]);\
11      if(raw_prob >= g_cal_x[n-1])  return(g_cal_y[n-1]);\
12      int lo = 0, hi = n - 1;\
13      while(hi - lo > 1)\
14        {\
15         int mid = (lo + hi) / 2;\
16         if(g_cal_x[mid] <= raw_prob) lo = mid;\
17         else hi = mid;\
18        }\
19      return(g_cal_y[lo]);  // piecewise constant: left-segment value\
20     }\
21//--- Platt scaling: sigmoid(A * x + B)\
22   return(1.0 / (1.0 + MathExp(-(g_platt_A * raw_prob + g_platt_B))));\
23  }\
24```\
25\
26### Implementing CPCV with the Strategy Tester\
27\
28#### Path Mask Loading\
29\
30
31Each Strategy Tester pass loads a single path mask file. Rather than using a hash map (which requires _Generic/HashMap.mqh_), the EA loads the timestamps into a sorted array and performs binary search for O(log n) lookup on each bar. For typical CPCV test windows of a few hundred to a few thousand bars, this is negligible overhead.\
32
33\
34
35Files are opened with the _FILE\_COMMON_ flag, which resolves paths relative to the terminal's _Common\\Files\_ folder. This flag is required; without it, the Strategy Tester cannot locate files written by the Python export script on some broker configurations.\
36
37\
38
39```\
40//+------------------------------------------------------------------+\
41//| LoadPathMask: load sorted timestamp array from path_N.csv.       |\
42//+------------------------------------------------------------------+\
43bool LoadPathMask(int path_index)\
44  {\
45   string fname = StringFormat(ARTIFACTS_DIR + "path_%d.csv", path_index);\
46   int fh = FileOpen(fname, FILE_READ | FILE_CSV | FILE_COMMON, ",");\
47   if(fh == INVALID_HANDLE)\
48     {\
49      PrintFormat("LoadPathMask: cannot open %s, error=%d", fname, GetLastError());\
50      return(false);\
51     }\
52   FileReadString(fh);   // skip header\
53   g_n_test_bars = 0;\
54\
55   while(!FileIsEnding(fh))\
56     {\
57      string ts_str = FileReadString(fh);\
58      if(StringLen(ts_str) == 0)\
59         continue;\
60      if(ArrayResize(g_test_bars, g_n_test_bars + 1) < 0)\
61        {\
62         FileClose(fh);\
63         return(false);\
64        }\
65      g_test_bars[g_n_test_bars] = StringToTime(ts_str);\
66      g_n_test_bars++;\
67     }\
68   FileClose(fh);\
69   ArraySort(g_test_bars);  // sort for binary search\
70   PrintFormat("LoadPathMask: path %d — %d bars", path_index, g_n_test_bars);\
71   return(g_n_test_bars > 0);\
72  }\
73\
74bool IsTestBar(datetime bar_time)\
75  {\
76   int lo = 0, hi = g_n_test_bars - 1;\
77   while(lo <= hi)\
78     {\
79      int mid = (lo + hi) / 2;\
80      if(g_test_bars[mid] == bar_time) return(true);\
81      if(g_test_bars[mid] < bar_time)  lo = mid + 1;\
82      else hi = mid - 1;\
83     }\
84   return(false);\
85  }\
86```\
87\
88#### Order Execution\
89\
90
91The _ExecuteOrder()_ function handles three cases: opening a new position, reversing an existing position when the signal flips, and closing when the signal drops below the minimum threshold. Every lot size is normalized via _NormalizeLot()_ before being passed to _CTrade_, and every order is guarded by a margin check using _OrderCalcMargin()_.\
92
93\
94
95```\
96//+------------------------------------------------------------------+\
97//| NormalizeLot: clamp to broker step and limits.                   |\
98//+------------------------------------------------------------------+\
99double NormalizeLot(string symbol, double raw_lot)\
100  {\
101   double step = SymbolInfoDouble(symbol, SYMBOL_VOLUME_STEP);\
102   double mn   = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MIN);\
103   double mx   = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MAX);\
104   double lot  = MathRound(raw_lot / step) * step;\
105   lot = MathMax(mn, MathMin(mx, lot));\
106   return(NormalizeDouble(lot, 2));\
107  }\
108\
109//+------------------------------------------------------------------+\
110//| CheckMargin: return false if margin is insufficient.             |\
111//+------------------------------------------------------------------+\
112bool CheckMargin(string symbol, double lots, ENUM_ORDER_TYPE order_type)\
113  {\
114   MqlTick tick;\
115   if(!SymbolInfoTick(symbol, tick))\
116      return(false);\
117   double price  = (order_type == ORDER_TYPE_SELL) ? tick.bid : tick.ask;\
118   double margin = 0.0;\
119   double free   = AccountInfoDouble(ACCOUNT_MARGIN_FREE);\
120   if(!OrderCalcMargin(order_type, symbol, lots, price, margin))\
121      return(false);\
122   return(margin <= free);\
123  }\
124```\
125\
126#### Parallelizing Across Paths\
127\
128
129In optimization mode, the Strategy Tester runs each _InpPathIndex_ value in a separate agent (0..φ − 1). This is not a parameter search; it is a reuse of the tester's parallel infrastructure for path simulation. For N=6, k=2 (φ=5 paths), configure the optimizer as follows:\
130
131\
132
133```\
134//--- Strategy Tester settings:\
135//---   Optimization:  Complete (slow)\
136//---   Model:         Every tick based on real ticks\
137//---   InpPathIndex:  from=0, to=4, step=1  (phi=5 for N=6, k=2)\
138//---   Custom criterion: OnTester() return value\
139\
140//+------------------------------------------------------------------+\
141//| OnTester: return path Sharpe as optimization criterion.          |\
142//+------------------------------------------------------------------+\
143double OnTester()\
144  {\
145   return(ComputePathSharpe());\
146  }\
147\
148//+------------------------------------------------------------------+\
149//| OnDeinit: close position, write equity CSV, release resources.   |\
150//+------------------------------------------------------------------+\
151void OnDeinit(const int reason)\
152  {\
153   ClosePosition();\
154   WritePathCSV(InpPathIndex);\
155   ReleaseIndicatorHandles();\
156   if(g_onnx_handle != INVALID_HANDLE)\
157      OnnxRelease(g_onnx_handle);\
158  }\
159```\
160\
161
162The date range in the Strategy Tester must cover the full events span, not just the test folds. The EA internally skips bars whose timestamps do not appear in its path mask. A date range shorter than the events span causes the EA to miss test bars that fall outside the tester window.\
163
164\
165### Path Reporting and Python Post-processing\
166\
167#### Collecting Path Results\
168\
169
170After all passes complete, each path has written a CSV to _Common\\Files\\ml\_artifacts\\results\\path\_N.csv_ containing the bar-level equity series for that path. The postprocessor reads these files, constructs the returns matrix, computes the path Sharpe distribution, and runs the PBO audit.\
171
172\
173
174The _compute\_pbo()_ function requires a _t1_ series aligned with the returns matrix index. Because CPCV is symmetric, we pass a neutral _t1_ where each timestamp serves as its own end-time:\
175
176\
177
178```\
179from pathlib import Path\
180import pandas as pd\
181import numpy as np\
182from afml.cross_validation.pbo import compute_pbo\
183\
184results_dir = Path(r"C:\...\Common\Files\ml_artifacts\results")\
185n_paths = 5   # phi = 5 for N=6, k=2\
186\
187path_series = []\
188for i in range(n_paths):\
189    df  = pd.read_csv(results_dir / f"path_{i}.csv", parse_dates=["timestamp"])\
190    eq  = df.set_index("timestamp")["equity"]\
191    ret = eq.pct_change().fillna(0)\
192    path_series.append(ret.rename(i))\
193\
194returns_matrix = pd.concat(path_series, axis=1).fillna(0)\
195\
196# Neutral t1 for symmetric CPCV (required parameter)\
197t1_neutral = pd.Series(returns_matrix.index, index=returns_matrix.index)\
198\
199# Path Sharpe distribution\
200path_sharpes = returns_matrix.apply(\
201    lambda s: s.mean() / s.std() * np.sqrt(252)\
202    if s.std() > 1e-9 else 0\
203)\
204\
205# PBO audit\
206pbo_result = compute_pbo(returns_matrix, t1=t1_neutral, n_folds=8)\
207\
208print(f"Median path Sharpe:  {path_sharpes.median():.3f}")\
209print(f"Path Sharpe std:     {path_sharpes.std():.3f}")\
210print(f"PBO:                 {pbo_result['pbo']:.4f}")\
211```\
212\
213#### Interpreting the Distribution\
214\
215
216Three questions determine whether the strategy is suitable for deployment:\
217
218\
2191. Is the median path Sharpe positive after costs?
220
221A strategy whose bar-level CPCV looked promising but whose tick-level distribution centers near zero has an edge that execution costs consume entirely. \
222
2232.
224
225Is the distribution tight? A wide distribution signals fragility.
226
227The model's performance depends on which temporal configuration it faced, not on a stable structural pattern. \
228
2293.
230
2315? PBO measures the probability that the best in-sample strategy configuration will underperform the median out-of-sample.
232
2335 means selection is no better than chance.
234
235\
236
237\
238
239Figure 2. 2-panel illustration of the CPCV path Sharpe distribution from tick-level simulation (N=6, k=2, φ=5 paths)\
240
241\
242- Panel (a): Cumulative returns for each of the five combinatorial paths, computed from bar-level equity recorded in _OnDeinit()_. All five paths are profitable; the dispersion is low.\
243- Panel (b): Path Sharpe ratios with the median marked as a dashed line. The three-number summary (median Sharpe: 0.71, std: 0.21, PBO: 0.11) satisfies all three deployment criteria.\
244\
245### Practical Walkthrough\
246\
247
248The end-to-end procedure from trained model to deployment decision consists of seven steps.\
249
250\
2511. Run the pipeline with ONNX export and calibration enabled:\
252\
253
254\
255
256\
257
258```\
259model, features, metrics, config = pipeline.run(\
260       calibrate=True, export_onnx=True\
261)\
262```\
263\
2642. Run the export script. This produces the five MQL5-consumable artifacts and prints the correct _InpPathIndex_ range:\
265\
266
267```\
268python export_pipeline_artifacts.py \\
269       --model-dir ./Models/.../a1b2c3d4 \\
270       --mql5-dir "C:\...\AppData\Roaming\MetaQuotes\Terminal\...\Common\Files" \\
271       --n-folds 6 --k-test 2\
272# Output: Exported 5 path masks — InpPathIndex: from=0, to=4\
273```\
274\
2753. Edit _feature\_spec.json_. The export script writes placeholder _type_ and _lookback_ values. Update each entry to match the actual indicator type and window used during Python feature engineering. The field order must match _feature\_names_ exactly.\
2764. Compile _CPCVBacktest.mq5_ in MetaEditor. Verify zero errors and zero warnings under _#property strict_.\
2775. Configure the Strategy Tester. Set symbol and period to match the training configuration. Select "Every tick based on real ticks". Set the date range to cover the full events span. In the optimization tab, set _InpPathIndex_ from=0, to=4, step=1. Select "Complete (slow)" optimization mode.\
2786. Run the optimization. Each of the five agents processes one path independently. On a machine with 8 cores, the run completes in roughly 1.5×–2× the time of a single pass.\
2797. Run the postprocessor:\
280\
281
282\
283
284\
285
286```\
287python cpcv_postprocess.py \\
288       --results-dir "C:\...\Common\Files\ml_artifacts\results" \\
289       --n-paths 5\
290```\
291\
292
293\
294
295If the median path Sharpe is positive, the standard deviation is below 0.5, and the PBO is below 0.5, the strategy has passed the tick-level evidence bar. The next step is forward testing on a demo account, which is outside the scope of this article.\
296
297\
298### Conclusion\
299\
300
301This article closes the loop from Python model training to MQL5 tick-accurate simulation. The pipeline exports five artifact types; an export script translates them into flat files. The EA loads those files at _OnInit()_, applies them bar by bar inside _OnTick()_, and reports path-level results via CSV and _OnTester()_. The Strategy Tester's parallelization runs all φ\[N, k\] paths concurrently, and Python assembles the returned series into a Sharpe distribution and PBO audit.\
302
303\
304
305Key points from the implementation:\
306
307\
308- The sklearn pipeline (StandardScaler + classifier) is exported as a single ONNX graph. \
309- MQL5's indicator API is handle-based: _iRSI()_, _iATR()_, and _iMA()_ return handles, not values.
310
311Values come from _CopyBuffer()_. \
312- The ONNX input tensor must be a 2D array of shape (1, n\_features).
313
314\
315- Path masks must be loaded from _Common\\Files\_ using the _FILE\_COMMON_ flag. \
316- For N=6, k=2: C(6,2)=15 splits, φ=5 paths.
317
318The Strategy Tester optimization must be configured with _InpPathIndex_ from=0, to=4, step=1. ).
319
320\
321
322\
323### Attached Files\
324\
325|  | File | Language | Description |\
326| --- | --- | --- | --- |\
327| 1. mq5_ | MQL5 | Expert advisor implementing the full _OnInit_ / _OnTick_ / _OnTester_ / _OnDeinit_ cycle.
328
329Loads ONNX model, calibrator, feature specification, and path mask. Applies Part 10 and Part 11 sizing logic.
330
331Writes per-path equity CSV on each optimization pass. |\
332| 2.
333
334mqh_ | MQL5 | Include file implementing _LoadFeatureSpec()_, _CreateIndicatorHandles()_, _BuildFeatureVector()_, and _ReleaseIndicatorHandles()_. json and manages all indicator handles.
335
336Returns raw (unscaled) feature values. |\
337| 3.
338
339mqh_ | MQL5 | Include file implementing _LoadCalibrator()_ and _ApplyCalibrator()_ for both isotonic (piecewise constant binary search) and Platt (sigmoid) methods. |\
340| 4.
341
342py_ | Python | Loads pipeline output via _load\_from\_path()_, extracts calibrator breakpoints, generates feature specification JSON, precomputes CPCV path masks, and copies the ONNX file to _Common\\Files\_. |\
343| 5.
344
345json.
346
347**Attached files** \|\
348
349\
350
351\
352
353[Download ZIP](https://www.mql5.com/en/articles/download/21954.zip "Download all attachments in the single ZIP archive")\
354
355\
356
357[CPCVBacktest.mq5](https://www.mql5.com/en/articles/download/21954/CPCVBacktest.mq5 "Download CPCVBacktest.mq5")(18.89 KB)\
358
359\
360
361[FeatureEngine.mqh](https://www.mql5.com/en/articles/download/21954/FeatureEngine.mqh "Download FeatureEngine.mqh")(14.59 KB)\
362
363\
364
365[Calibrator.mqh](https://www.mql5.com/en/articles/download/21954/Calibrator.mqh "Download Calibrator.mqh")(5.63 KB)\
366
367\
368
369[cpcv\_postprocess.py](https://www.mql5.com/en/articles/download/21954/cpcv_postprocess.py "Download cpcv_postprocess.py")(6.11 KB)\
370
371\
372
373[export\_pipeline\_artifacts.py](https://www.mql5.com/en/articles/download/21954/export_pipeline_artifacts.py "Download export_pipeline_artifacts.py")(10.62 KB)\
374
375\
376
377**Warning:** All rights to these materials are reserved by MetaQuotes Ltd. Copying or reprinting of these materials in whole or in part is prohibited.\
378
379\
380
381This 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.\
382
383\
384
385\
386
387\
388
389[Patrick Murimi Njoroge](https://www.mql5.com/en/users/patricknjoroge743 "Patrick Murimi Njoroge")\
390
391\
392- [Kenya](https://www.mql5.com/go?https://maps.google.com/?z=4&q=Kenya "Lives")\
393- [6528](https://www.mql5.com/en/users/patricknjoroge743/achievements "Rating")\
394\
395#### Other articles by this author\
396\
397- [Position Management: Scaling Into Winners With A Falling-Risk Pyramid](https://www.mql5.com/en/articles/22684)\
398- [Beyond the Clock (Part 2): Building Runs Bars in MQL5](https://www.mql5.com/en/articles/22213)\
399- [Meta-Labeling the Classics (Part 1): Filtering and Sizing RSI Trades](https://www.mql5.com/en/articles/22274)\
400- [Feature Engineering for ML (Part 4): Implementing Time Features in MQL5](https://www.mql5.com/en/articles/22517)\
401- [MetaTrader 5 Machine Learning Blueprint (Part 16): Nested CV for Unbiased Evaluation](https://www.mql5.com/en/articles/22040)\
402- [Feature Engineering for ML (Part 3): Session-Aware Time Features for Forex Machine Learning](https://www.mql5.com/en/articles/22516)\
403\
404
405**[Go to discussion](https://www.mql5.com/en/forum/510757)**\
406
407\
408
409[MQL5 Trading Tools (Part 34): Replacing Native Chart Objects with an Interactive Canvas Drawing Layer](https://www.mql5.com/en/articles/22786)\
410
411\
412
413We replace native MetaTrader chart objects with a canvas-based drawing engine that renders tools pixel-by-pixel on a full-chart bitmap layer. The article implements persistent object storage with per-tool style memory, precise hit testing, selection, whole-object dragging, and handle manipulation. It also adds new line tools, a reorganized category system with a one-click delete action, and a rubber-band preview for multi-click placement.\
414
415\
416
417[Automating Classic Market Methods in MQL5 (Part 1): Wyckoff Accumulation and Distribution](https://www.mql5.com/en/articles/22628)\
418
419\
420
421The article describes an MQL5 EA that automates Wyckoff accumulation and distribution via a finite state machine. It confirms spring to SOS and upthrust to SOW before placing LPS or LPSY entries, using relative tick volume as the confirmation metric. Readers get the state model, detection criteria, code organization, and MetaTrader 5 testing procedure.\
422
423\
424
425[Features of Experts Advisors](https://www.mql5.com/en/articles/1494)\
426
427\
428
429Creation of expert advisors in the MetaTrader trading system has a number of features.\
430
431\
432
433[Seasonality Indicator by Hours, Days of the Week, and Days of the Month](https://www.mql5.com/en/articles/18672)\
434
435\
436
437The article explains how to develop a tool for analyzing recurring price patterns in financial markets — by day of the month (1-31), day of the week (Monday-Sunday), or hour of the day (0-23). The indicator analyzes historical data, calculates the average return for each period, and displays the results as a histogram with a forecast. It includes customizable parameters: seasonality type, number of bars analyzed, display as percentages or absolute values, chart colors.\
438
439\
440
441[We've created a channel for MQL5 developersFollow MQL5.community on social media and be the first to receive important updatesLearn more![](https://www.mql5.com/ff/sh/a83xrgctr82w45z9z2/01.png)](https://www.mql5.com/ff/go?link=https://www.mql5.com/en/forum/455636%3Futm_source=www.mql5.com%26utm_medium=display%26utm_content=follow.channel%26utm_campaign=AAA380.mql5.socials&a=pwgdbvtemvkwsfltqonysypfvtrtufji&s=e99a66a1660cd810b1edbac65597df695e2c2220d1e937834f402f9aeabd4289&uid=&ref=https://www.mql5.com/en/articles/21954&id=wdausxxqrpvhekbwjrjlhqjghyhesrqqau&fz_uniq=5097745083969374623)\
442
443\
444
445This website uses cookies. Learn more about our [Cookies Policy](https://www.mql5.com/en/about/cookies).\
446
447\
448
449\
450
451\
452
453\
454
455\
456
457You are missing trading opportunities:\
458
459\
460- Free trading apps\
461- Over 8,000 signals for copying\
462- Economic news for exploring financial markets\
463\
464
465RegistrationLog in\
466
467\
468
469latin characters without spaces\
470
471\
472
473a password will be sent to this email\
474
475\
476
477An error occurred\
478
479\
480
481\
482- [Log in With Google](https://www.mql5.com/en/auth_oauth2?provider=Google&amp;return=popup&amp;reg=1)\
483\
484
485You agree to [website policy](https://www.mql5.com/en/about/privacy) and [terms of use](https://www.mql5.com/en/about/terms)\
486
487\
488
489If you do not have an account, please [register](https://www.mql5.com/en/auth_register)\
490
491\
492
493Allow the use of cookies to log in to the MQL5.com website.\
494
495\
496
497Please enable the necessary setting in your browser, otherwise you will not be able to log in.\
498
499\
500
501[Forgot your login/password?](https://www.mql5.com/en/auth_forgotten?return=popup)\
502
503\
504- [Log in With Google](https://www.mql5.com/en/auth_oauth2?provider=Google&amp;return=popup)
RoboForex