Forex walk-forward validation: keep future labels out of training

6 min readMetaKit

Sorting a forex dataset by time does not automatically make its backtest free of look-ahead bias. A training row can sit before the test period while its target uses prices from inside that period. A feature can have an old timestamp while being calculated from information published later. Both errors let the research process see evidence it would not have had at the decision time.

This guide defines a walk-forward validation boundary using information availability. It is for developers building their own research pipeline from MT5 data, rather than a walkthrough of the terminal interface. The Strategy Tester guide covers that separate workflow. MetaKit provides data access; it does not expose a public walk-forward optimization service.

Give each research row three clocks

Record a decision time, the latest availability time of every feature used for that decision, and the time when the training target becomes observable. These are research metadata you create; they are not three extra fields supplied by MetaKit's candle endpoint.

For example, a decision at 10:00 may use the close of the 09:00–10:00 hourly bar, provided the execution model allows receipt and processing after the bar ends. A target describing the next three hours is not fully known until 13:00. At a test boundary of 12:00, that row's target is still unavailable even though its decision time is in the past.

An MT5 bar's timestamp identifies its opening time in the native MqlRates definition. Do not treat that timestamp as proof that its final high, low or close was known then. Assign availability from the data's actual meaning, collection process and any assumed delay.

For a historical calendar feature, the final revised release value is also different from the first published value. If you lack point-in-time evidence, exclude that feature from a claim of historical real-time feasibility or clearly disclose the limitation. A tidy train/test split cannot repair future information already embedded in inputs.

Separate chronological splitting from label purging

The official TimeSeriesSplit reference describes ordered training/test splits and a gap measured in samples. It also notes the equal-spacing condition for comparable test durations. That is a useful starting point, not an automatic detector of variable-length trading labels.

For this walkthrough, a training row is eligible only when its decision precedes the first test decision and its label is fully available strictly before that boundary. The strict comparison deliberately excludes a label that completes exactly at the boundary, because the example assumes no time for receiving and fitting on it. Another execution model can choose a different boundary, but must state it.

With uniformly spaced samples and a fixed label horizon, a sample gap may be sufficient. If exits determine label length, inspect the actual target end time for each row. A Friday-to-Monday gap also shows why a number of rows and a number of clock hours are different objects.

Audit a fold without running a strategy

This JavaScript function generates expanding training sets and nonoverlapping test blocks from normalized research rows. All times are integer milliseconds in a validated common time reference. It rejects features known after their own decision and removes training labels that reach the next test boundary.

function availabilitySplits(rows, testStarts, testSize) {
  if (!Array.isArray(rows) || rows.length < 2 ||
      !Array.isArray(testStarts) || testStarts.length === 0 ||
      !Number.isSafeInteger(testSize) || testSize < 1) {
    throw new Error("Invalid split configuration");
  }
  rows.forEach((r, i) => {
    if (!r || ![r.decisionAt, r.featureAvailableAt, r.labelEndAt]
        .every((x) => Number.isSafeInteger(x) && x >= 0) ||
        r.featureAvailableAt > r.decisionAt ||
        r.labelEndAt < r.decisionAt ||
        (i > 0 && r.decisionAt <= rows[i - 1].decisionAt)) {
      throw new Error("Invalid timing or a feature availability leak");
    }
  });
  let previousEnd = 0;
  return testStarts.map((start) => {
    if (!Number.isSafeInteger(start) || start < 1 ||
        start < previousEnd || start + testSize > rows.length) {
      throw new Error("Test blocks must fit and not overlap");
    }
    previousEnd = start + testSize;
    const boundary = rows[start].decisionAt;
    const train = [];
    for (let i = 0; i < start; i++) {
      if (rows[i].labelEndAt < boundary) train.push(i);
    }
    if (train.length === 0) throw new Error("No eligible training labels");
    const test = Array.from({ length: testSize }, (_, j) => start + j);
    return { train, test, purged: start - train.length };
  });
}

For a synthetic fixture, create twelve hourly decision rows indexed 0 through 11. Each row's features are available at its decision and each label ends two hours later. For test starts [6, 9] and test size 3, the first fold trains on [0, 1, 2, 3] and tests on [6, 7, 8]. Rows 4 and 5 are purged. The second fold trains on [0, 1, 2, 3, 4, 5, 6] and tests on [9, 10, 11].

Previously tested rows can enter later training when their labels have become available. That models a specified refitting procedure. It does not allow you to use their test scores to redesign the strategy without counting that redesign as another research choice.

This helper is not a complete backtester or a general cross-validation framework. It assumes one row per strictly increasing decision time; multiple instruments sharing a decision timestamp need time-grouped folds instead. It does not validate feature construction, simulate execution or infer target timing from raw candles. Test those boundaries independently.

Fit every learned transformation inside the fold

A scaler, feature selector or learned threshold can leak even if the estimator sees only training rows. The scikit-learn leakage guidance recommends splitting before fitting transformations and describes pipelines that keep fitting confined to training data.

Within each fold, use only eligible training rows to choose model parameters and preprocessing state. Apply those frozen transformations to the test block. A causal indicator may read earlier prices to warm up; it must not consume a future price or use a globally fitted normalization statistic. Audit the dependency timestamps rather than banning all historical context at the boundary.

Keep parameter searches and selection rules inside the research design. Reserve a final interval before inspecting outcomes, and do not repeatedly tune against it while still calling it untouched. Record unsuccessful variants too. A single favorable held-out result is evidence about one test, not a guarantee that a strategy will profit later.

Keep execution and data checks separate

MetaKit's GET /v1/accounts/{id}/candles provides broker candle observations for your own processing. Preserve account/feed identity, exact symbol, timeframe, requested interval and collection date with the dataset. Run the OHLC quality audit before creating labels, and resolve unfinished bars and unexplained history gaps.

Define when a signal can become an order. A rule using the completed bar's close cannot simply assume it traded at that same historical close with no receipt delay, spread or slippage. OHLC data alone also cannot establish the order of two intrabar price touches. Use a suitable execution model and disclose where the data cannot resolve an outcome.

Save one manifest per fold: eligible row identifiers, purged rows, feature version, selected parameters, fitting cutoff, execution assumptions and test metrics. That makes a changed result traceable to a changed input or rule. For collection details, start with the MetaKit API documentation and keep the validation engine in your application.

Sources and verification

Verified 21 October 2026. The split fixture was exercised independently; it is illustrative and reports no strategy returns. Product scope was checked against current routes.