The MetaTrader 5 Strategy Tester can rank optimization passes by balance, profit factor, expected payoff, drawdown, recovery factor or Sharpe ratio. Each of those built in criteria has the same weakness: it will happily rank a pass with 14 trades and one lucky winner above a pass with 400 steady trades.
The fix is to write your own criterion. MQL5 lets an Expert Advisor define an OnTester() function that runs once at the end of every test pass and returns a single number. If you choose "Custom max" as the optimization criterion, the tester ranks passes by that number.
This tutorial builds a criterion that rejects passes you would never trade, and then scores the rest in a way that rewards robustness instead of luck.
The basics
OnTester() takes no parameters and returns a double. Inside it, TesterStatistics() gives you the same figures you see in the tester report.
double OnTester()
{
double trades = TesterStatistics(STAT_TRADES);
double profit = TesterStatistics(STAT_PROFIT);
return profit;
}
Returning plain profit like this reproduces the default balance criterion, which is not very useful. The value comes from what you add.
Step one: hard filters
Some passes should never win, however good their headline number. Return 0 for them so they sink to the bottom of the results.
input int InpMinTrades = 100; // minimum trades for a pass to count
input double InpMaxDDPercent = 25.0; // maximum relative equity drawdown
double OnTester()
{
double trades = TesterStatistics(STAT_TRADES);
double lossTrades = TesterStatistics(STAT_LOSS_TRADES);
double profit = TesterStatistics(STAT_PROFIT);
double ddPercent = TesterStatistics(STAT_EQUITY_DDREL_PERCENT);
if(trades < InpMinTrades) return 0.0; // too few trades to judge
if(lossTrades < 1) return 0.0; // no losers usually means a broken test
if(profit <= 0.0) return 0.0;
if(ddPercent > InpMaxDDPercent) return 0.0;
return profit;
}
The minimum trade count is the most important line. Small samples produce extreme statistics in both directions, and the optimizer is very good at finding them.
A pass with zero losing trades is also worth rejecting. In real strategies it almost always means the stop loss was never hit because of a modelling problem, a grid or martingale that has not met its bad day yet, or a test period that is far too short.
Step two: score what is left
Profit on its own ignores how much pain it took to earn it. Recovery factor, which is net profit divided by maximum drawdown, is a better starting point because it rewards return per unit of drawdown.
On top of that, it helps to shrink the score for passes that only just cleared the trade count filter. A simple way to do that without any fancy statistics is to divide by a factor that approaches 1 as the trade count grows.
double OnTester()
{
double trades = TesterStatistics(STAT_TRADES);
double lossTrades = TesterStatistics(STAT_LOSS_TRADES);
double profit = TesterStatistics(STAT_PROFIT);
double ddPercent = TesterStatistics(STAT_EQUITY_DDREL_PERCENT);
double recovery = TesterStatistics(STAT_RECOVERY_FACTOR);
if(trades < InpMinTrades) return 0.0;
if(lossTrades < 1) return 0.0;
if(profit <= 0.0) return 0.0;
if(ddPercent > InpMaxDDPercent) return 0.0;
// 100 trades divides the score by 1.5, 400 trades by 1.125
double sampleFactor = 1.0 + 50.0 / trades;
return recovery / sampleFactor;
}
The exact constant is a judgement call. The point is that two passes with the same recovery factor should not be ranked equally when one is based on four times as many trades.
Step three: check what the optimizer is really choosing
Once you switch to a custom criterion, look at the top 20 passes rather than only the best one. You want to see:
- Neighbouring parameter values scoring similarly. If the best pass is surrounded by zeros, it is a spike, not a plateau.
- Trade counts well above your minimum, not clustered right on it.
- Drawdown comfortably under your limit, not just under it.
If the top of the list is dominated by passes that barely clear the filters, the filters are doing their job and the strategy may simply not have enough edge.
Practical notes
- OnTester() also runs in a single test, not only in optimization. The value is shown in the report as the OnTester result, which is handy for checking your formula on one known pass.
- Keep the criterion inputs in the EA so you can change thresholds without recompiling, but exclude them from the optimization itself.
- If you use forward testing in the optimizer, compare the custom score of the backtest and forward parts of the same pass. A large drop is the clearest warning sign you will get.
- Do not divide by a statistic that can be zero. Recovery factor and profit factor need care when there are no losing trades, which is one more reason for the filter above.
A custom criterion does not make a strategy better. It stops the optimizer from rewarding the passes most likely to fall apart live.
Top comments (1)
For this criterion, an all-rejected optimisation still has a top row: every rejected pass scores zero. An explicit valid-pass count beside the ranking would prevent “best” being read as “acceptable”. I would also freeze the trade threshold and sample penalty before looking at forward results. Changing those after seeing the forward drop turns the forward set into another training set.