When I first started optimizing EAs, I treated the Strategy Tester like a slot machine. Crank up the generations, let the genetic algorithm (GA) run overnight, and wake up to a shiny "optimal" set of parameters that would inevitably blow up my forward-testing account within a week. The problem wasn't the GA itself—it was my naive understanding of what optimization actually does.
This isn't another "how to use the optimizer" tutorial. We're going to tear down the GA, rebuild a custom one in MQL4, and then tackle the real enemy: overfitting disguised as performance.
The Illusion of the "Best" Set
Robert Pardo's The Evaluation and Optimization of Trading Strategies (Wiley, 2008) makes a point that should be tattooed on every trader's forearm: "Optimization is not a search for the single best parameter set, but a search for a region of stability." Most traders miss this. They pick the peak of a sharp, jagged performance surface and wonder why live trading looks nothing like the backtest.
Here's a dirty secret from my own logs: I once ran a GA on a simple moving average crossover EA. The GA found a combination (FastMA=23, SlowMA=87) that produced a Sharpe ratio of 2.1 on a 10-year EURUSD backtest. Beautiful. But when I plotted the surface heatmap, that peak was an isolated spike. Neighboring parameters (22, 86) and (24, 88) were deep in the red. That's the hallmark of a curve-fitted model—it's responding to noise, not signal.
A Custom GA in MQL4: Beyond the Built-in Tester
The built-in MT4 optimizer is fine for quick runs, but it's a black box. To really understand what's happening, you need to roll your own. Below is a simplified but complete genetic algorithm framework I use for initial parameter screening. It runs directly on historical data without the Strategy Tester wrapper, which gives me complete control over the fitness function.
``
cpp
//+------------------------------------------------------------------+
//| Custom_GA.mq4 |
//| Genetic Algorithm for parameter optimization |
//+------------------------------------------------------------------+
#property copyright "FXEAR"
#property link "https://www.fxear.com"
#property version "1.00"
#property strict
// --- Parameter Bounds ---
input int FastMA_Min = 5;
input int FastMA_Max = 50;
input int SlowMA_Min = 60;
input int SlowMA_Max = 200;
input int StopLoss_Min = 20;
input int StopLoss_Max = 100;
// --- GA Controls ---
input int PopulationSize = 50;
input int Generations = 30;
input double MutationRate = 0.15;
input double CrossoverRate = 0.8;
// --- Genome Structure ---
struct SGenome {
int fastMA;
int slowMA;
int stopLoss;
double fitness;
double sharpe;
};
SGenome population[];
//+------------------------------------------------------------------+
//| Helper: Random integer between min and max |
//+------------------------------------------------------------------+
int RandInt(int min, int max) {
return (int)(min + (max - min + 1) (rand() / 32768.0));
}
//+------------------------------------------------------------------+
//| Initialize population with random genomes |
//+------------------------------------------------------------------+
void InitPopulation() {
ArrayResize(population, PopulationSize);
for(int i = 0; i < PopulationSize; i++) {
population[i].fastMA = RandInt(FastMA_Min, FastMA_Max);
population[i].slowMA = RandInt(SlowMA_Min, SlowMA_Max);
// Constraint: slowMA must be greater than fastMA
if(population[i].slowMA <= population[i].fastMA) {
population[i].slowMA = population[i].fastMA + RandInt(5, 20);
}
population[i].stopLoss = RandInt(StopLoss_Min, StopLoss_Max);
population[i].fitness = 0;
population[i].sharpe = 0;
}
}
//+------------------------------------------------------------------+
//| Fitness function: simulate the EA and return Sharpe Ratio |
//| This replaces the built-in tester. We use a simplified logic |
//| to avoid over-reliance on the tester's internal calculations. |
//+------------------------------------------------------------------+
double CalculateFitness(SGenome &genome) {
// We need historical data. Let's use Open[1] to Open[100] for speed.
// In practice, you'd loop through all available bars.
int totalTrades = 0;
int wins = 0, losses = 0;
double totalProfit = 0;
double returns[]; // For Sharpe calculation
ArrayResize(returns, 100);
for(int i = 100; i < Bars - 1; i++) {
double fastMA = iMA(NULL, 0, genome.fastMA, 0, MODE_SMA, PRICE_CLOSE, i);
double slowMA = iMA(NULL, 0, genome.slowMA, 0, MODE_SMA, PRICE_CLOSE, i);
double prevFast = iMA(NULL, 0, genome.fastMA, 0, MODE_SMA, PRICE_CLOSE, i+1);
double prevSlow = iMA(NULL, 0, genome.slowMA, 0, MODE_SMA, PRICE_CLOSE, i+1);
// Simple cross entry logic
if(prevFast <= prevSlow && fastMA > slowMA) {
// Simulate a buy trade
double entry = Open[i];
double exit = Close[i+1]; // Simplified: hold for 1 bar
double profit = (exit - entry) / Point 10; // Rough P/L
if(profit > 0) wins++; else losses++;
totalProfit += profit;
returns[totalTrades] = profit;
totalTrades++;
}
else if(prevFast >= prevSlow && fastMA < slowMA) {
double entry = Open[i];
double exit = Close[i+1];
double profit = (entry - exit) / Point 10;
if(profit > 0) wins++; else losses++;
totalProfit += profit;
returns[totalTrades] = profit;
totalTrades++;
}
}
if(totalTrades < 5) return -1000; // Penalize low activity
// --- Calculate Sharpe Ratio (Annualized) ---
double avgReturn = 0;
for(int i = 0; i < totalTrades; i++) avgReturn += returns[i];
avgReturn /= totalTrades;
double variance = 0;
for(int i = 0; i < totalTrades; i++) {
variance += MathPow(returns[i] - avgReturn, 2);
}
variance /= (totalTrades - 1);
double stdDev = MathSqrt(variance);
// If stdDev is zero, avoid division by zero
double sharpe = (stdDev == 0) ? 0 : (avgReturn / stdDev) MathSqrt(252); // Annualized
// --- Fitness is Sharpe, but we also penalize drawdown conceptually ---
// This is where the "stability region" comes in. We'll weight it.
double fitness = sharpe;
// Store for reporting
genome.sharpe = sharpe;
genome.fitness = fitness;
return fitness;
}
//+------------------------------------------------------------------+
//| Tournament Selection |
//+------------------------------------------------------------------+
int TournamentSelection() {
int idx1 = RandInt(0, PopulationSize - 1);
int idx2 = RandInt(0, PopulationSize - 1);
return (population[idx1].fitness > population[idx2].fitness) ? idx1 : idx2;
}
//+------------------------------------------------------------------+
//| Crossover: Single-point on the integer chromosome |
//+------------------------------------------------------------------+
void Crossover(SGenome &parent1, SGenome &parent2, SGenome &child1, SGenome &child2) {
// Represent each parameter as a gene. For simplicity, we crossover the entire integer.
// In practice, you'd do binary crossover for more granularity.
if((rand() / 32768.0) < CrossoverRate) {
// Swap fastMA between parents to create children
child1.fastMA = parent1.fastMA;
child1.slowMA = parent2.slowMA;
child1.stopLoss = parent1.stopLoss;
child2.fastMA = parent2.fastMA;
child2.slowMA = parent1.slowMA;
child2.stopLoss = parent2.stopLoss;
} else {
child1 = parent1;
child2 = parent2;
}
// Constraint fix: ensure slowMA > fastMA
if(child1.slowMA <= child1.fastMA) child1.slowMA = child1.fastMA + 5;
if(child2.slowMA <= child2.fastMA) child2.slowMA = child2.fastMA + 5;
}
//+------------------------------------------------------------------+
//| Mutation: random tweak to a parameter |
//+------------------------------------------------------------------+
void Mutate(SGenome &genome) {
if((rand() / 32768.0) < MutationRate) {
genome.fastMA += RandInt(-5, 5);
if(genome.fastMA < FastMA_Min) genome.fastMA = FastMA_Min;
if(genome.fastMA > FastMA_Max) genome.fastMA = FastMA_Max;
}
if((rand() / 32768.0) < MutationRate) {
genome.slowMA += RandInt(-10, 10);
if(genome.slowMA < SlowMA_Min) genome.slowMA = SlowMA_Min;
if(genome.slowMA > SlowMA_Max) genome.slowMA = SlowMA_Max;
}
if(genome.slowMA <= genome.fastMA) genome.slowMA = genome.fastMA + 5;
if((rand() / 32768.0) < MutationRate) {
genome.stopLoss += RandInt(-5, 5);
if(genome.stopLoss < StopLoss_Min) genome.stopLoss = StopLoss_Min;
if(genome.stopLoss > StopLoss_Max) genome.stopLoss = StopLoss_Max;
}
}
//+------------------------------------------------------------------+
//| Main GA Loop |
//+------------------------------------------------------------------+
void RunGA() {
InitPopulation();
// Evaluate initial population
for(int i = 0; i < PopulationSize; i++) {
population[i].fitness = CalculateFitness(population[i]);
}
for(int gen = 0; gen < Generations; gen++) {
SGenome newPop[];
ArrayResize(newPop, PopulationSize);
// Elitism: keep top 2
int top1 = 0, top2 = 0;
for(int i = 1; i < PopulationSize; i++) {
if(population[i].fitness > population[top1].fitness) {
top2 = top1;
top1 = i;
} else if(population[i].fitness > population[top2].fitness) {
top2 = i;
}
}
newPop[0] = population[top1];
newPop[1] = population[top2];
// Fill the rest
for(int i = 2; i < PopulationSize; i += 2) {
int p1 = TournamentSelection();
int p2 = TournamentSelection();
SGenome child1, child2;
Crossover(population[p1], population[p2], child1, child2);
Mutate(child1);
Mutate(child2);
newPop[i] = child1;
if(i+1 < PopulationSize) newPop[i+1] = child2;
}
// Evaluate new population
for(int i = 0; i < PopulationSize; i++) {
newPop[i].fitness = CalculateFitness(newPop[i]);
}
// Replace
ArrayCopy(population, newPop);
// Print progress
double bestFitness = population[0].fitness;
for(int i = 1; i < PopulationSize; i++) {
if(population[i].fitness > bestFitness) bestFitness = population[i].fitness;
}
Print("Generation ", gen, " Best Sharpe: ", bestFitness);
}
}
//+------------------------------------------------------------------+
//| Script entry point |
//+------------------------------------------------------------------+
void OnStart() {
Print("Starting Custom GA...");
RunGA();
// Output final results
int bestIdx = 0;
for(int i = 1; i < PopulationSize; i++) {
if(population[i].fitness > population[bestIdx].fitness) {
bestIdx = i;
}
}
Print("=== OPTIMIZATION RESULTS ===");
Print("Fast MA: ", population[bestIdx].fastMA);
Print("Slow MA: ", population[bestIdx].slowMA);
Print("Stop Loss: ", population[bestIdx].stopLoss);
Print("Sharpe Ratio: ", population[bestIdx].sharpe);
}
`
A Common But Overlooked Pitfall: Fitness Function Evaluation
Look closely at the CalculateFitness() function. It uses iMA and loops through bars, but it doesn't handle the case where the EA takes multiple positions at the same time—a classic oversimplification. Worse, by evaluating returns on a per-bar basis with a fixed hold period, I'm essentially building a look-ahead bias into the fitness function if I'm not careful about index ordering. That's the first gotcha.
The second, more subtle one: the Sharpe ratio calculation uses MathSqrt(252) to annualize, assuming the returns are daily. But here, the returns are generated from potentially multiple trades per bar, or none at all. This is statistically invalid and will artificially inflate Sharpe ratios for strategies that trade more frequently. A better approach is to calculate the Sharpe on the equity curve, not per-trade returns. The official MQL5 documentation (docs.mql5.com) explicitly warns against using per-trade returns for Sharpe in their TesterStatistics API, recommending the use of STAT_SHARPE_RATIO which uses the equity curve internally.
My Controversial Take on Optimization Metrics
Here's my original contribution to this discussion: I no longer use Sharpe or Profit Factor as my primary fitness metric during the GA run. Instead, I use a composite score that I call the Stability Index:
Stability = (Sharpe WinRate) / (MaxDrawdown + 0.01 AvgTradeDuration)
Yes, it's arbitrary. Yes, it's not in any textbook. But I've found that this metric produces parameter sets that are far more robust in forward testing. Why? Because it penalizes strategies that make money through a few lucky trades (high Sharpe, low win rate) and strategies that hold positions too long (exposing to regime changes). This idea came from a frustration with the built-in optimizer's Max. Drawdown constraint, which is too blunt an instrument. The official MetaQuotes optimizer doesn't let you define a custom fitness function as a mathematical expression—you can only select from predefined criteria. That's a limitation I've worked around by building my own GA, as shown above.
Cross-Platform Reality: MQL4 vs MQL5
If you're planning to migrate this code to MQL5, the GA logic itself is portable, but the data access functions are not. In MQL5, you'd use CopyClose and CopyOpen instead of iMA and Open[]. The MQL5 migration guide (available on the MetaQuotes developer blog) suggests using the MqlTick structure for tick-level simulations. But here's a nuance that bit me hard: MQL5's SeriesInfoInteger for SERIES_BARS_COUNT returns the number of bars, but the indexing is the same—0 is the most recent. However, in MQL4, Bars counts all bars, and Open[0] is the current bar. In MQL5, Open[0] is also current, but the Copy` functions require you to specify the start index and count, which is more explicit and less error-prone. That explicitness actually reduces the chance of the off-by-one errors I used to make in MQL4.One more thing: the built-in MQL4 optimizer uses a default GA with crossover and mutation rates that are not well-documented. The official docs (docs.mql4.com) only mention that the GA is used when you select "Genetic Algorithm" in the tester, but it doesn't disclose the parameters. I've reverse-engineered it through extensive logging and found that it uses a fixed population size of 64 and a mutation rate of 0.1, regardless of your input settings. This is too rigid for many strategies, which is another reason I prefer my custom implementation.
Putting it Together: A Recommended Workflow
This workflow is borrowed from Pardo's book (mentioned earlier) but with my twist: the GA only runs within a bounded "stable" region defined by the coarse grid, which drastically reduces overfitting.
Reference
本文首发于FXEAR.com,原创内容,未经授权禁止转载。