Edgoro
Expert Advisor · MQL5

MQL5 OnTester: a custom optimization criterion

Short answer

Add a double OnTester() function to the EA and return your score; in the Strategy Tester's settings choose "Custom max" as the optimization criterion, and passes are ranked by that value. Read the pass's results with TesterStatistics() (STAT_PROFIT, STAT_TRADES, STAT_EQUITY_DDREL_PERCENT...) or rebuild them from the deal history, and return 0 for passes you want to rule out.

UsesOnTesterTesterStatisticsSTAT_RECOVERY_FACTORHistorySelectINIT_PARAMETERS_INCORRECT

The code

A complete Expert Advisor with a small RSI strategy to optimize and a custom criterion. It opens real positions if attached to a live chart: it's meant for the Strategy Tester.

Save it as MQL5/Experts/ontester-custom-optimization-criterion.mq5 and compile it in MetaEditor (F7): 0 errors, 0 warnings.

MQL5
#include <Trade/Trade.mqh>

// ——— A small strategy to optimize: RSI leaves oversold / overbought ———
input int    InpRsiPeriod  = 14;       // RSI period
input double InpLow        = 30.0;     // Buy when RSI crosses back above
input double InpHigh       = 70.0;     // Sell when RSI crosses back below
input int    InpStopPoints = 400;      // Stop-loss, points
input int    InpTakePoints = 400;      // Take-profit, points
input double InpLots       = 0.10;     // Volume, lots
// ——— Custom criterion ———
input int    InpMinTrades  = 50;       // Fewer trades than this scores 0
input double InpMaxDdPct   = 20.0;     // A relative equity drawdown above this scores 0, %

CTrade   trade;
int      rsi = INVALID_HANDLE;
datetime lastBar = 0;

int OnInit()
  {
   rsi = iRSI(_Symbol, PERIOD_CURRENT, InpRsiPeriod, PRICE_CLOSE);
   if(rsi == INVALID_HANDLE)
      return INIT_FAILED;
   trade.SetExpertMagicNumber(20261112);
   return INIT_SUCCEEDED;
  }

void OnDeinit(const int reason)
  {
   IndicatorRelease(rsi);
  }

void OnTick()
  {
   datetime bar = iTime(_Symbol, PERIOD_CURRENT, 0);
   if(bar == lastBar || PositionSelect(_Symbol))
      return;
   double v[];
   ArraySetAsSeries(v, true);
   if(CopyBuffer(rsi, 0, 1, 2, v) != 2)
      return;
   lastBar = bar;
   double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
   double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID);
   if(v[1] < InpLow && v[0] >= InpLow)
      trade.Buy(InpLots, _Symbol, ask, NormalizeDouble(bid - InpStopPoints * _Point, _Digits), NormalizeDouble(bid + InpTakePoints * _Point, _Digits));
   else if(v[1] > InpHigh && v[0] <= InpHigh)
      trade.Sell(InpLots, _Symbol, bid, NormalizeDouble(ask + InpStopPoints * _Point, _Digits), NormalizeDouble(ask - InpTakePoints * _Point, _Digits));
  }

// R² of the balance curve, trade by trade: 1.0 for a perfectly straight rising line, near 0 for noise
double BalanceLinearity()
  {
   if(!HistorySelect(0, TimeCurrent()))
      return 0.0;
   double balance = TesterStatistics(STAT_INITIAL_DEPOSIT);
   double sx = 0, sy = 0, sxx = 0, syy = 0, sxy = 0;
   int    n  = 0;
   for(int i = 0; i < HistoryDealsTotal(); i++)
     {
      ulong deal = HistoryDealGetTicket(i);
      if(deal == 0)
         continue;
      long type = HistoryDealGetInteger(deal, DEAL_TYPE);
      if(type != DEAL_TYPE_BUY && type != DEAL_TYPE_SELL)
         continue;                                   // the initial deposit is a deal too
      balance += HistoryDealGetDouble(deal, DEAL_PROFIT) + HistoryDealGetDouble(deal, DEAL_SWAP)
                 + HistoryDealGetDouble(deal, DEAL_COMMISSION);
      if(HistoryDealGetInteger(deal, DEAL_ENTRY) == DEAL_ENTRY_IN)
         continue;                                   // entry commission counted; one point per closing deal
      double x = n;
      sx += x;
      sy += balance;
      sxx += x * x;
      syy += balance * balance;
      sxy += x * balance;
      n++;
     }
   if(n < 3)
      return 0.0;
   double vx = n * sxx - sx * sx;
   double vy = n * syy - sy * sy;
   double cov = n * sxy - sx * sy;
   if(vx <= 0.0 || vy <= 0.0 || cov <= 0.0)          // flat or falling curve: no score (and no division by zero)
      return 0.0;
   return (cov * cov) / (vx * vy);
  }

// Called once at the end of every test pass. With "Custom max" selected, the optimizer ranks passes by this value.
double OnTester()
  {
   double trades = TesterStatistics(STAT_TRADES);
   double profit = TesterStatistics(STAT_PROFIT);
   double ddPct  = TesterStatistics(STAT_EQUITY_DDREL_PERCENT);
   if(trades < InpMinTrades || profit <= 0.0 || ddPct > InpMaxDdPct)
      return 0.0;
   // Smoothness first, then profit per unit of drawdown, so one lucky trade can't win the optimization
   double recovery = TesterStatistics(STAT_RECOVERY_FACTOR);
   return BalanceLinearity() * MathMin(recovery, 10.0);
  }

How it works

  • OnTester(). Called once at the end of every test pass, single tests included. Its return value is the pass's "Custom" result in the optimization table and "OnTester result" in a single test's report.
  • The filters. Fewer trades than InpMinTrades, a loss, or a relative equity drawdown above InpMaxDdPct score 0, so those passes sink to the bottom whatever else they did.
  • BalanceLinearity(). Rebuilds the balance after each closing deal from the deal history and returns R², the share of its variation explained by a straight line: near 1 for a steady rise, near 0 for a curve made of a few big jumps. The initial deposit is a deal too, which is why only buy and sell deals are counted.
  • Commission on entry deals. Entry deals carry commission but no profit: they're added to the balance without adding a point, so the curve has one point per closed trade.
  • STAT_RECOVERY_FACTOR capped at 10. Profit divided by maximum drawdown rewards return per unit of pain, and the cap stops a pass with an unusually tiny drawdown from dominating the ranking.
  • The guards in BalanceLinearity(). A flat or falling curve returns 0 before any division, so the score can't become infinite or not-a-number.

Pitfalls

  • Not selecting "Custom max". OnTester() runs anyway, but the optimizer ranks by whatever criterion is selected, usually balance, and the custom score is just a column.
  • Dividing by zero. A pass with no losing trade has a drawdown of 0, and a criterion such as profit / drawdown then divides by zero. Guard every division; zero divide explains why doubles don't always stop the program.
  • Too few trades. A pass with four trades can show a perfect curve. Without a minimum, the optimizer rewards luck.
  • Reading the wrong statistic. STAT_EQUITY_DDREL_PERCENT is the largest equity drawdown in percent; STAT_BALANCE_DD is in money and ignores open trades. Pick the one your rule is about.
  • Optimizing too many inputs. Every extra input multiplies the combinations, and the best of millions of passes is mostly noise. Keep the criterion and the inputs few.

Variations

  • Profit factor with a floor. TesterStatistics(STAT_PROFIT_FACTOR) with the same minimum trade count is a simple alternative; what's a good profit factor puts the numbers in context.
  • Sharpe-like scores. Collect the result of each trade in an array while rebuilding the history, then return the mean divided by the standard deviation (guarding a zero deviation).
  • Extra data per pass. OnTesterInit(), FrameAdd() and OnTesterPass() send any array from each pass back to the terminal, to analyse more than one number.
  • The strategy part. The RSI rule is only there to have something to optimize; the indicator reading example explains the CopyBuffer() calls it uses.

Why not just maximize profit?

Ask the optimizer for maximum profit and it finds the inputs that fitted the past best, often through a handful of outsized trades that won't repeat. A criterion that also demands enough trades, a steady balance curve and a limited drawdown pushes it toward settings that worked more evenly, which is a better starting point but still no promise. Check the chosen settings on data the optimization never saw: walk-forward analysis shows how, and backtesting mistakes that inflate results lists the traps around it.

Questions

Where do I see the value OnTester() returns?

In an optimization, in the Result column when the criterion is Custom max, and in the Custom column otherwise. After a single test, in the report's OnTester result line.

Does OnTester() run during live trading?

No. It's only called by the Strategy Tester, at the end of each pass. Code in it has no effect on a live chart.

Does the genetic algorithm use the custom criterion?

Yes. With Custom max selected, the genetic optimizer breeds the passes with the highest OnTester() values, so a criterion that returns 0 for bad passes also steers the search away from them.

This program is a minimal example written to show one technique, not a trading system, and none of it is a recommendation to trade. Trading involves substantial risk of loss and is not suitable for every investor. Edgoro is a software tool, not a broker or financial adviser: nothing it produces is investment advice, and past or backtested performance does not guarantee future results.

All MQL5 errors

Want it inside a complete EA?

Edgoro's MT5 EA generator writes EAs ready for the Strategy Tester; ask for a custom OnTester() criterion and it's included. A clean build proves the code compiles, not that a strategy works: test it on a demo account.