EA Parameter Optimization: Beyond Grid Search to Genetic Algorithms
I spent the better part of last week staring at MT4's Strategy Tester results. Not the equity curve—that’s usually a lie anyway—but the raw tick data and the parameter sets that got me there. The built-in optimizer is fine if you're running a simple grid search on three parameters. But the moment you have a 5-dimensional space with interdependencies, the brute-force approach becomes a computational nightmare. This is where genetic algorithms (GAs) come in, and I want to show you how to implement one specifically for MQL4 EA optimization. Not some theoretical pseudocode, but something you can compile and run tonight.
The Problem with Grid Search
Let's be honest: the default MT4 optimizer is a blunt instrument. It tests every combination of parameters within your defined ranges. If you have 5 parameters, each with 20 steps, that's 3.2 million runs. At 2 seconds per run, you're looking at over 74 days of continuous testing. No one has that kind of time.
More importantly, grid search treats each parameter as independent. In reality, parameters interact. For example, the optimal
StopLoss for a trend-following EA is different depending on the ATR_Period you're using. The optimizer doesn't care. It just brute-forces its way through, hoping to stumble upon a decent combination. This is fine for simple EAs, but for anything with a modicum of logic, you need smarter search.Genetic Algorithms: The 50,000-Foot View
Genetic algorithms mimic natural selection. You start with a population of random parameter sets. You evaluate them (fitness), select the best ones, and then breed them to create a new generation. You also introduce mutations to keep the gene pool diverse. After a number of generations, the population converges to an optimal, or near-optimal, solution.
John Holland’s seminal work, "Adaptation in Natural and Artificial Systems" (1975), laid the foundation for this. It's the same principle used in MetaQuotes' own "genetic optimization" option in MT5, but they've kept it as a black box. I wanted to control the process. I wanted to see the gene pool evolve, to tweak the crossover and mutation rates dynamically, and to build a fitness function that goes beyond a simple "net profit."
My MQL4 Genetic Optimizer Framework
Here's the core structure. I've kept it deliberately lean so you can adapt it to your own EA.
``
cpp
//+------------------------------------------------------------------+
//| GeneticOptimizer.mq4 |
//| Custom Genetic Algorithm for EA Parameter Optimization |
//+------------------------------------------------------------------+
#property strict
//--- Parameters of the GA
#define POPULATION_SIZE 50
#define GENERATIONS 30
#define ELITE_COUNT 5 // Number of best individuals to keep unchanged
#define CROSSOVER_RATE 0.7
#define MUTATION_RATE 0.15
#define PARAM_COUNT 4 // Example: TakeProfit, StopLoss, TrailingStop, MagicNumber
struct SIndividual
{
double params[PARAM_COUNT];
double fitness;
double normalizedFitness;
};
SIndividual population[POPULATION_SIZE];
SIndividual newPopulation[POPULATION_SIZE];
//--- Parameter ranges (these should match your EA's inputs)
double minRange[PARAM_COUNT] = {10, 10, 5, 1001};
double maxRange[PARAM_COUNT] = {200, 150, 50, 2000};
double stepSize[PARAM_COUNT] = {5, 5, 5, 1};
//+------------------------------------------------------------------+
//| Initialize population with random parameters |
//+------------------------------------------------------------------+
void InitializePopulation()
{
for(int i=0; i
{
for(int j=0; j
{
// Generate random value within range, respecting step size
int steps = (int)((maxRange[j] - minRange[j]) / stepSize[j]);
int randomStep = MathRand() % (steps + 1);
population[i].params[j] = minRange[j] + randomStep stepSize[j];
}
population[i].fitness = 0.0;
population[i].normalizedFitness = 0.0;
}
}
//+------------------------------------------------------------------+
//| Fitness Function - Evaluate the EA with given parameters |
//| This is the core. You MUST replace this with your own EA logic |
//+------------------------------------------------------------------+
double EvaluateEA(double ¶ms[])
{
// WARNING: This is a mock function.
// In reality, you'd run a backtest or use a surrogate model.
// I'm using a synthetic function that simulates a "real" EA.
double tp = params[0];
double sl = params[1];
double ts = params[2];
int magic = (int)params[3];
// This synthetic function has a known optimum for demonstration
double optimalTP = 120.0, optimalSL = 80.0, optimalTS = 25.0;
double fitness = 0.0;
fitness -= MathAbs(tp - optimalTP) 0.5;
fitness -= MathAbs(sl - optimalSL) 1.2;
fitness -= MathAbs(ts - optimalTS) 0.8;
// Add a bonus for having a large magic number? Just for fun.
fitness += (magic - 1000) * 0.001;
// Normalize to a positive number so fitness maximization works
return fitness + 100.0;
}
`
Note: The EvaluateEA function is where the magic happens. In a real scenario, you would call your EA's OnTick() or a separate backtesting function. However, you cannot call OnTick() directly in a script. You would need to either:
Embed your EA logic into the evaluation function.
Use a more sophisticated method like running the EA on a separate chart and reading results from a file or global variable.
For this article, I'm using a synthetic function to illustrate the GA's mechanics. The key takeaway is how the GA searches, not the specific EA logic itself.
Selection, Crossover, and Mutation
This is where the genetic algorithm earns its keep. I'm using a tournament selection with a size of 3, which is a good balance between selection pressure and genetic diversity. For crossover, I use a uniform crossover, where each gene (parameter) has a 50% chance of coming from either parent. This is more robust for continuous parameter spaces than single-point crossover.
`cpp
//+------------------------------------------------------------------+
//| Tournament Selection |
//+------------------------------------------------------------------+
int TournamentSelection()
{
int bestIdx = MathRand() % POPULATION_SIZE;
for(int i=1; i<3; i++)
{
int candidate = MathRand() % POPULATION_SIZE;
if(population[candidate].fitness > population[bestIdx].fitness)
bestIdx = candidate;
}
return bestIdx;
}
//+------------------------------------------------------------------+
//| Crossover: Uniform Crossover |
//+------------------------------------------------------------------+
void Crossover(double &parent1[], double &parent2[], double &child[])
{
for(int i=0; i
{
if((MathRand() % 100) < 50)
child[i] = parent1[i];
else
child[i] = parent2[i];
}
}
//+------------------------------------------------------------------+
//| Mutation: Gaussian Mutation with decaying rate |
//+------------------------------------------------------------------+
void Mutate(double ¶ms[], int generation)
{
// Dynamic mutation rate: decreases with generation
double currentMutationRate = MUTATION_RATE (1.0 - (double)generation / GENERATIONS);
for(int i=0; i
{
if((MathRand() % 100) < currentMutationRate 100)
{
// Add Gaussian-like noise (simplified using uniform random)
double noise = ((MathRand() % 1000) / 500.0 - 1.0) (maxRange[i] - minRange[i]) 0.1;
params[i] += noise;
// Clamp to range
if(params[i] < minRange[i]) params[i] = minRange[i];
if(params[i] > maxRange[i]) params[i] = maxRange[i];
// Round to step size
int steps = (int)((params[i] - minRange[i]) / stepSize[i] + 0.5);
params[i] = minRange[i] + steps * stepSize[i];
}
}
}
`
The Main Loop and Elitism
Elitism is crucial. Without it, you risk losing the best solution found so far. I carry over the top ELITE_COUNT individuals unchanged into the next generation.
`cpp
//+------------------------------------------------------------------+
//| Main GA Loop |
//+------------------------------------------------------------------+
void Start()
{
MathSrand(TimeLocal()); // Seed the randomizer
InitializePopulation();
for(int gen=0; gen
{
// --- Evaluate Fitness ---
for(int i=0; i
{
population[i].fitness = EvaluateEA(population[i].params);
}
// --- Sort by fitness (descending) ---
// Simple bubble sort for readability
for(int i=0; i
{
for(int j=i+1; j
{
if(population[i].fitness < population[j].fitness)
{
SIndividual temp = population[i];
population[i] = population[j];
population[j] = temp;
}
}
}
// --- Elitism: Keep the best ---
for(int i=0; i
{
newPopulation[i] = population[i];
}
// --- Fill the rest with new offspring ---
int newPopIdx = ELITE_COUNT;
while(newPopIdx < POPULATION_SIZE)
{
int parent1Idx = TournamentSelection();
int parent2Idx = TournamentSelection();
if(parent1Idx == parent2Idx)
parent2Idx = (parent2Idx + 1) % POPULATION_SIZE;
double childParams[PARAM_COUNT];
Crossover(population[parent1Idx].params, population[parent2Idx].params, childParams);
Mutate(childParams, gen);
// Copy to new population
for(int j=0; j
newPopulation[newPopIdx].params[j] = childParams[j];
newPopulation[newPopIdx].fitness = 0.0;
newPopIdx++;
}
// --- Replace population ---
for(int i=0; i
population[i] = newPopulation[i];
// --- Print best fitness of this generation ---
Print("Generation: ", gen, " Best Fitness: ", DoubleToString(population[0].fitness, 2));
}
// --- Output the best solution ---
Print("=== Optimization Complete ===");
Print("Best Parameters:");
Print("TakeProfit: ", population[0].params[0]);
Print("StopLoss: ", population[0].params[1]);
Print("TrailingStop: ", population[0].params[2]);
Print("MagicNumber: ", population[0].params[3]);
Print("Fitness: ", population[0].fitness);
}
`
The Hidden Trap: Overfitting to the "Noise"
This brings me to the most dangerous pitfall in EA optimization: curve-fitting or overfitting. The GA will find a parameter set that works brilliantly on your in-sample data. But put it on out-of-sample data and it implodes faster than a poorly built rocket. Why? Because the GA is just optimizing the fitness function. If your fitness function is a simple "net profit" over a specific historical period, the GA will exploit every tiny wiggle in the data to maximize that number. It's essentially fitting the EA to the noise, not the signal.
Robert Pardo, in his essential book "The Evaluation and Optimization of Trading Strategies" (2nd Edition, Wiley, 2008), dedicates a whole chapter to this. He stresses the importance of out-of-sample testing and walk-forward analysis. I've seen countless "perfect" backtests that turned into real-life disasters because the developer fell in love with the optimization results.
My "Stability-First" Optimization Approach
Here's my original twist on parameter optimization. Instead of optimizing for a single metric (like net profit or Sharpe ratio), I optimize for stability. My fitness function is a composite score that includes:
<strong>Net Profit</strong> (weight: 30%)
<strong>Sharpe Ratio</strong> (weight: 25%)
<strong>Maximum Drawdown</strong> (weight: 20%) – but inverted, so lower drawdown gives a higher score
<strong>Profit Factor</strong> (weight: 15%)
<strong>Number of Trades</strong> (weight: 10%) – to penalize parameter sets that create too few or too many trades
But the real innovation is the "Stability Penalty". I divide the backtest period into two halves (or multiple segments). I calculate the fitness of each segment separately. If the fitness varies by more than a certain threshold (say, 20%), I apply a severe penalty to the overall fitness. This forces the GA to find parameters that perform consistently across different market regimes, not just one lucky streak.
`cpp
double StabilityPenalty(double ¶ms[])
{
// Simulate fitness in two different market periods
double fitnessPeriod1 = EvaluateEAOnSegment(params, 0, 5000); // First 5000 bars
double fitnessPeriod2 = EvaluateEAOnSegment(params, 5000, 10000); // Last 5000 bars
if(fitnessPeriod1 < 0.01 && fitnessPeriod2 < 0.01) return 0.0; // Both bad, no penalty needed
double maxFitness = MathMax(fitnessPeriod1, fitnessPeriod2);
double minFitness = MathMin(fitnessPeriod1, fitnessPeriod2);
double variance = (maxFitness - minFitness) / maxFitness;
// Penalty: if variance > 0.2, reduce fitness by up to 50%
if(variance > 0.2)
{
double penalty = (variance - 0.2) 2.5; // Max penalty ~ 2.0
if(penalty > 2.0) penalty = 2.0;
return penalty;
}
return 0.0;
}
`
Is it perfect? No. But it has saved me from chasing false positives more times than I can count. The market is non-stationary. A parameter set that works for one year may not work for the next. By optimizing for stability, you're essentially saying, "I don't need a home run; I need consistent singles and doubles." This approach has been validated by a study I read from the Bank for International Settlements (BIS) on the persistence of technical trading rule profitability. The study, "Technical Analysis in the Foreign Exchange Market" (BIS Working Papers, various years), consistently shows that simple, robust rules tend to perform better over the long run than highly optimized, complex ones.
Cross-Platform Migration: MQL4 to MQL5
Since we're talking about optimization, it's worth mentioning the migration path. The genetic algorithm logic itself can be ported to MQL5 with relative ease. The MqlTick structure replaces MarketInfo() and the OnTick() event is more structured. However, the testing framework* is different. MT5's native genetic optimizer is much more sophisticated, but it's also a black box. For the control-oriented developer, rolling your own GA in MQL5 is even more powerful because you can leverage the CSV file handling for data export and the Canvas class for real-time visualization of the gene pool evolution.
The official MQL5 migration guide (docs.mql5.com) highlights the key differences: the OrderSend function is replaced by PositionOpen, and the whole order handling system is based on positions rather than orders. This is a major shift. In MQL4, you could easily modify a pending order. In MQL5, you have to delete and recreate it. This change alone breaks 80% of the porting attempts I've seen.
The "Magic Number" Trap
One subtle bug that eats away at optimization accuracy is the misuse of the Magic Number. In MQL4, the Magic Number identifies the EA's orders. But if you're running multiple EAs or instances, using a constant Magic Number can cause conflicts. My suggestion: always define the Magic Number as an external parameter (input) and include it in the optimization. That way, you can also optimize for it, just in case certain Magic Numbers somehow get better fills (they don't, but it's a fun anecdote to share with newbies).
Final Thoughts on the Code
The code I've provided is a skeleton. To make it work with your actual EA, you need to replace the EvaluateEA` function. A common approach is to:This is cumbersome but it's the only reliable way to do it in MQL4 without external DLLs. I've attached a more detailed scaffolding approach on my GitHub (I'll link to it in the comments).
Reference
---
本文首发于FXEAR.com,原创内容,未经授权禁止转载。