Русский
preview
Evolutionary Selection of LLM Agents in MetaTrader 5

Evolutionary Selection of LLM Agents in MetaTrader 5

MetaTrader 5 — Trading systems |
292 0
Yevgeniy Koshtenko
Yevgeniy Koshtenko

Imagine that you have hired 20 traders with fundamentally different trading styles. One trades momentum only, the second looks for pullbacks toward the mean, the third hunts for breakouts, and the fourth trades exclusively based on z-score statistics. You pay them all the same amount — regardless of the outcome. A month later, you discover that five of them have generated all the profits, while the remaining fifteen are either breaking even or in the red. What will you do?

Any sensible manager would fire the underperformers and hire more people similar to the top performers — but with slight differences in their approach — to find even better combinations. It is precisely this logic that forms the basis of this article. But instead of traders, there are LLM agents with various system prompts; instead of a salary, there are computing resources; and instead of a manager, there is a genetic algorithm that automatically eliminates the weak, clones the strong ones with mutations, and continuously improves the population directly during trading.

Most articles about LLM-powered Expert Advisors describe a single agent with a single fixed prompt. This prompt is written once and never changes. But the market is not constant: in a trending market, the best agent is the one who follows momentum; in a sideways market, it is the one who trades off key levels; and during periods of high volatility, it is the one who knows how to wait. A fixed prompt is a strategy frozen in time: it is either good right now, or it is not. It does not adapt, learn, or evolve.

By the end of this article, you'll have an architecture consisting of 20 LLM agents based on xAI's Grok. It performs evolutionary selection every 20 trades: it removes the bottom 25% by fitness, clones the top 25% with a prompt mutation, and publishes the leaderboard on the MetaTrader 5 chart.


Why Grok, and Why No SDK?

The previous version of this system used DeepSeek-R1 via GitHub Models. This required installing the OpenAI SDK, obtaining a GitHub token, and working through an Azure proxy. Grok by xAI simplifies integration: a direct REST API (api.x.ai) and a standard Bearer token are available. The only dependency is the `requests` library, which is usually already included in the Python environment. No SDKs, no intermediate proxies.

The second key difference is that Grok does not return <think>...</think> blocks in standard mode, unlike DeepSeek's reasoning models. This simplifies parsing the response: there is no need to remove the reasoning before extracting the JSON.


Why a Genetic Algorithm Specifically?

Genetic algorithms are an optimization method inspired by biological evolution. Holland's seminal work (John Holland, *Adaptation in Natural and Artificial Systems*, 1975) describes three operators: selection, crossover, and mutation. In the context of trading systems, genetic algorithms (GAs) have been used to optimize strategy parameters since the late 1990s — the MetaTrader 5 platform itself uses a genetic optimizer in its strategy tester.

Our approach differs in that the unit of evolution is the LLM agent's system prompt, rather than the numerical parameters of an indicator. This creates a fundamentally new search space — the space of trading philosophies expressed in natural language.

The key question in a genetic algorithm is how to measure the quality of an individual. The naive answer, “by PnL,” is incorrect: an agent with three profitable trades out of three looks better than an agent with 50 out of 70, although statistically the latter is more reliable. We use the weighted Sharpe ratio:

Fitness = Sharpe × √(number_of_trades)

where Sharpe = mean(PnL) / std(PnL).

The √N multiplier rewards agents that are not only profitable, but also active. Agents with fewer than three trades receive a fitness score of 0 and are not included in the ranking. Important: the server log shows "personal_trades" — the number of personal trades for a specific agent, not the total number of trades across the entire population. Confusion between these two numbers is a common reason for the false impression that “fitness is not improving.”

Choosing an agent for the next analysis involves striking a balance between exploitation and exploration. For validated agents, a softmax roulette based on fitness is used, while agents without sufficient statistics are given a cumulative 30% chance of being selected — newborn mutants are guaranteed trials for an objective evaluation.


Prompt Mutation: How to Change a Philosophy Without Breaking It

Mutating a language prompt is a non-trivial task. Unlike numerical parameters, “change the prompt by 5%” does not have a clear-cut meaning. We implement four types of controlled mutation, each applied with a probability of 0.3.

The first is replacing the style adjective: “pure momentum trader” → “adaptive momentum trader,” “systematic trend follower” → “ruthless trend follower.” This changes the agent’s aggressiveness and flexibility without affecting its underlying logic.

The second is replacing a key strategy concept: momentum ↔ velocity, trend ↔ impulse, RSI ↔ stochastic oscillator. The agent retains the general idea but begins to view the market through a different tool.

The third is shifting numerical thresholds by ±5–15%: RSI > 70 → RSI > 68 or RSI > 75; ATR period 14 → period 10 or 20. The subtlest mutation, changing sensitivity without changing the logic.

The fourth is adding a behavioral suffix: “Always confirm with at least two independent signals.” or “Adapt aggressiveness based on recent win streak.” This adds a meta-rule on top of the existing philosophy.

Example. Original prompt of the SIGMA agent:

"You are SIGMA — a quantitative statistician. You see only probabilities and z-scores. "
"Buy when price is more than 1.5 standard deviations below its 20-period mean. "
"Sell when price is more than 1.5 standard deviations above its 20-period mean. "
"Emotions and patterns are noise; only statistical edge matters."

Its descendant after two generations of mutations (SIGMA_G2):

"You are SIGMA — a disciplined statistician. You see only probabilities and z-scores. "
"Buy when price is more than 1.5 standard deviations below its 20-period mean. "
"Sell when price is more than 1.5 standard deviations above its 18-period mean. "
"Emotions and patterns are noise; only statistical edge matters. "
"Always confirm with at least two independent signals."

The changes are subtle but fundamental: quantitative → disciplined (adaptation instead of mechanics), 20-period → 18-period (faster response), and a double-confirmation requirement has been added. The philosophy has been preserved — the character has changed.


Architecture: How It All Works Together

The system consists of two components: a Python server with an evolutionary engine and an MQL5 Expert Advisor. A key difference from previous versions is the synchronous processing of the EVOLVE command and protection against concurrent writes to the socket:

MetaTrader 5 (EA_Evolution_Grok.mq5)
    │
    ├─ EVOLVE:SYMBOL:csv  ──────────────────────────────────┐
    │                                                       │
    ├─ RESULT:SYMBOL:agent_id:pnl  ─────────────────────┐   │
    │                                               WebSocket
    │                                                   │   │
Python server (llm_server_evolution_grok.py)            │   │
    │                                                   │   │
    ├─ EvolutionEngine                                  │   │
    │   ├─ Pool of 20 agents                            │   │
    │   ├─ Softmax-agent selection──────────────────────┘   │
    │   ├─ calc_indicators() + build_market_context()       │
    │   └─ evolve() every 20 trades                         │
    │                                                       │
    └─ Grok (xAI API)  ◄────────────────────────────────────┘
        {"signal","comment","confidence"}

On each new bar, the Expert Advisor collects the last 60 closing prices and sends them to the server with the EVOLVE:SYMBOL:csv command. The server selects an agent (using softmax roulette), calculates 11 indicators, builds the context, and synchronously queries Grok. The model returns JSON containing the signal, a comment, and a confidence score. If the confidence level exceeds the InpMinConf threshold, the Expert Advisor opens a position and records the agent_id.

When closing a position, the Expert Advisor locates it in the trade history using DEAL_POSITION_ID — a reliable method that has been tested in live trading. It then calculates the PnL in normalized points and sends RESULT:SYMBOL:agent_id:pnl to the server. The server updates the agent's personal statistics. After every 20 trades across the entire population, an evolutionary cycle is automatically triggered.


Three Critical Issues You Need to Know About

The development of this system revealed several nontrivial problems whose solutions are not obvious from the documentation.

Thread race condition on the server. In a naive implementation, the EVOLVE command starts a separate thread to call Grok (threading.Thread(_run).start()). While the thread waits 10–15 seconds for a response, the main loop continues to process incoming commands — including EVOLVE_STATUS — and calls conn.sendall() in parallel with the Grok thread. Two threads write to a single socket without locking; the bytes get jumbled; the Expert Advisor receives garbage instead of JSON and interprets it as "hold." Solution: EVOLVE is processed synchronously, and all outgoing messages go through send_lock.

EVOLVE_STATUS flood. In the Strategy Tester, a timer with a 30-second interval (EventSetTimer(30)) triggers on every bar, rather than in real time. On M15, this means 20+ EVOLVE_STATUS requests per second. Solution: remove EventSetTimer entirely; the status is queried once every 10 bars directly in OnTick. Deduplication has been added on the server: repeated responses to EVOLVE_STATUS are rate-limited to no more than once every 2 seconds.

JSON instruction format. A template of the form "signal":"buy"|"sell"|"hold" and "confidence":0.0-1.0 is not valid JSON. Grok interprets it literally and either returns a string containing the | character or chooses the conservative default — hold. Solution: show a specific working example: {"signal": "buy", "comment": "Strong uptrend", "confidence": 0.82}, add "temperature": 0.2, and explicitly state that "hold" is a rare exception.


Population: 20 Agents and Their Philosophies

Agent Philosophy
ATLAS Pure momentum based on price velocity
ORACLE Mean reversion using RSI and Bollinger Bands
FALCON Breakout hunter (breakout + ATR expansion)
COMPASS Systematic MA trend follower
DEVIL Contrarian trader: buys on panic, sells on euphoria
STORM Trades only in low-volatility conditions
RAZOR Aggressive MA5 scalper
STONE Price action without indicators — just structure
SHIELD Conservative: only A+ setups — better to skip
PENDULUM RSI extremes with triple confirmation
CROSS MA crossovers with a momentum filter
PILLAR Support/resistance + candlestick confirmation
SPIRAL Fibonacci retracements after impulsive moves
PRISM Multi-timeframe synthesis (H4 trend, M15 entry)
PULSE Analysis of the volume/price relationship
DIVERGE RSI divergence relative to price
WAVE Swing trading using the stochastic oscillator
HYDRA Adaptive hybrid: identifies the regime and changes the logic
THUNDER Aggressive breakout: accepts false signals as the price to pay for real ones
SIGMA Quantitative statistician: only z-scores and deviations

All agents receive the same market context with 11 indicators: current price, MA5/20/50, RSI(14), Stochastic(14), ATR(14), BB(mid/lo/hi), z-score, and trend alignment. The consistency of the input data, combined with a variety of interpretations, ensures a fair comparison.


Key Code Snippets

The Agent class stores all statistics and calculates fitness:

@dataclass
class Agent:
    agent_id:    int
    name:        str
    prompt:      str
    generation:  int   = 0
    trades:      int   = 0
    pnl_history: List[float] = field(default_factory=list)
    alive:       bool  = True

    @property
    def fitness(self) -> float:
        if self.trades < 3:
            return 0.0
        avg = sum(self.pnl_history) / self.trades
        std = float(np.std(self.pnl_history)) or 1.0
        return (avg / std) * math.sqrt(self.trades)

Calling Grok — without an SDK, using only `requests`, with `temperature=0.2` for decisive signals:

def ask_grok(system_prompt: str, user_message: str) -> str:
    enforced_system = (
        system_prompt + "\n\n"
        "OUTPUT RULE: Reply with ONLY one valid JSON object.\n"
        'Example: {"signal": "buy", "comment": "reason", "confidence": 0.82}\n'
        "signal must be exactly: buy  sell  or  hold\n"
        "BIAS: 'hold' only for perfectly ambiguous conditions. Commit to a direction."
    )
    payload = {
        "model": "grok-3-mini",
        "max_tokens": 1200,
        "temperature": 0.2,
        "messages": [
            {"role": "system", "content": enforced_system},
            {"role": "user",   "content": user_message},
        ],
    }
    resp = requests.post("https://api.x.ai/v1/chat/completions",
                         headers=HEADERS, json=payload, timeout=60)
    resp.raise_for_status()
    return resp.json()["choices"][0]["message"]["content"].strip()
The synchronous EVOLVE handler with `send_lock` — the key fix for thread races:
send_lock = threading.Lock()

def safe_send(data: bytes):
    with send_lock:
        conn.sendall(data)

# In `handle_client`:
if cmd.startswith("EVOLVE:"):
    prices = [float(x) for x in csv.split(",") if x.strip()]
    result = evolve_analyze(sym, prices)   # synchronously, without a thread
    safe_send(ws_encode(json.dumps(result, ensure_ascii=False)))
    continue

On the MQL5 side, the Expert Advisor tracks the open position and looks for the closing deal using DEAL_POSITION_ID — this is more reliable than searching by order ticket:

double GetDealProfit(ulong posTicket)
{
   HistorySelect(0, TimeCurrent() + 86400);
   int    total = HistoryDealsTotal();
   double pnl   = 0.0;
   bool   found = false;
   for(int i = total - 1; i >= MathMax(0, total - 100); i--)
   {
      ulong dTicket = HistoryDealGetTicket(i);
      ulong dPos    = (ulong)HistoryDealGetInteger(dTicket, DEAL_POSITION_ID);
      if(dPos != posTicket) continue;
      ENUM_DEAL_ENTRY entry =
         (ENUM_DEAL_ENTRY)HistoryDealGetInteger(dTicket, DEAL_ENTRY);
      if(entry == DEAL_ENTRY_OUT || entry == DEAL_ENTRY_INOUT)
      {
         pnl  += HistoryDealGetDouble(dTicket, DEAL_PROFIT)
               + HistoryDealGetDouble(dTicket, DEAL_SWAP);
         found = true;
      }
   }
   return found ? pnl : EMPTY_VALUE;
}
```

---

## Getting Started in Three Steps

Get API key xAI on [console.x.ai](https://console.x.ai) — new accounts receive an initial credit. Install the single dependency: `pip install requests numpy`. Insert the key into the `XAI_API_KEY` line and start the server with the command `python llm_server_evolution_grok.py`. Compile and run `EA_Evolution_Grok.mq5` in MetaTrader 5.

After the first 20 trades of the population, you will see the following in the console:
```
[11:42:17] [EVO #1] Elite:  ['SIGMA', 'HYDRA', 'ORACLE', 'COMPASS']
[11:42:17] [EVO #1] Culled: ['THUNDER', 'DEVIL', 'RAZOR', 'STONE']
[11:42:17] [EVO #1] Born: SIGMA_G1   (parent=SIGMA,   id=20)  personal_trades=0
[11:42:17] [EVO #1] Born: HYDRA_G1   (parent=HYDRA,   id=21)  personal_trades=0
[11:42:17] [EVO #1] Born: ORACLE_G1  (parent=ORACLE,  id=22)  personal_trades=0
[11:42:17] [EVO #1] Born: COMPASS_G1 (parent=COMPASS, id=23)  personal_trades=0
[11:42:17] [EVO #1] Population: 20 agents alive | Evolutions done: 1

Normalizing PnL in points (rather than in money) allows agents to be compared correctly across different instruments and lot sizes.

Pay attention to `personal_trades` in the logs. If an agent shows fitness=0.000, that is normal: it does not yet have three personal trades. Fitness will become nonzero only after the third trade of that specific agent, regardless of the total number of trades in the population.


What Is Important to Keep in Mind

Latency for Grok via the xAI API is 1–5 seconds for standard queries, which is significantly faster than DeepSeek-R1 via GitHub Models. For the M5 timeframe and above, this is perfectly acceptable.

The system starts working correctly after 50+ trades for the population; before that, the fitness scores are too noisy. The parameter EVOLUTION_INTERVAL = 20 is the minimum sufficient value. The sum of ELITE_RATIO and CULL_RATIO must not exceed 0.6; otherwise, the population will collapse into a single clone. The mutation intensity mutation_strength = 0.3 is the sweet spot.

Never use EventSetTimer together with the Strategy Tester — the timer does not run in real time and turns into a flood-request generator. For recurring tasks, use a bar counter.

An important limitation of the current version is the lack of crossover: a true GA combines genes from two parents, rather than merely mutating one. Combining the first paragraph of the SIGMA prompt with the second paragraph of the HYDRA prompt is an interesting challenge for the next iteration. Another approach is to use Grok's built-in real-time search to enrich the market context with the latest news right in the agent's prompt.


Test Results

The system was tested on the EUR/USD pair, using the M15 timeframe, from February 1 to March 16, 2026, on the RoboForex-ECN platform (Build 5672). Modeling quality: 100%, initial deposit: USD 10,000, leverage: 1:500, lot size: 0.01.

During the test period, the system executed 22 trades (44 orders, taking into account paired opening and closing orders). Net profit was USD 1.51, with a Profit Factor of 1.04 and a Sharpe Ratio of 0.14 — the results were modest, but the system ended the period in the black with a maximum balance drawdown of just 0.23% (USD 23.28).

The percentage of profitable trades was 45.45%, while the average winning trade (USD 4.35) was noticeably higher than the average losing trade (USD 3.48). The best trade yielded USD 18.73 (ATLAS agent, short position from February 26 to March 3), and the largest loss was USD 9.56 (EVO_DEVIL’s last open position, which was forcibly closed at the end of the test).

DEVIL turned out to be the most active agent — a contrarian trader who executed 5 of the 22 trades and posted mixed results: both the best trade of the test (USD 18.65 on a short position in early March) and a final loss at the end. THUNDER, CROSS, PULSE, and WAVE also traded actively.

It is important to consider the context: 22 trades is the minimum number required for the evolutionary cycle to run just once (every 20 trades in the population). The system's true potential becomes apparent after 50+ trades, when the agents' fitness scores become reliable enough to support meaningful selection.


Conclusion

At the beginning of the article, we faced the problem of a fixed prompt — a strategy frozen in time. We now have a living ecosystem of 20 agents, each with its own trading philosophy, and a mechanism that continuously tests the robustness of these philosophies.

A backtest on EURUSD M15 showed a modest but positive result: USD 1.51 in net profit with a drawdown of 0.23% over six weeks of trading. This is not the kind of result people boast about in marketing brochures. But that is not the purpose of this work. The goal is to prove that the system is capable of independently identifying viable agents, weeding out non-viable ones, and reproducing the best ones with improvements — and it succeeds in doing so. The ATLAS agent caught the best trade in the test not because it was programmed for a specific pattern in February 2026, but because evolutionary selection gave it the opportunity to prove itself under the right conditions.

Two directions are clear for the next iteration. First, prompt crossover: a true genetic algorithm combines the genes of two parents, rather than simply mutating one. Combining SIGMA's analytical rigor with HYDRA's adaptability is a challenge on an entirely different level than replacing an adjective in a system prompt. Second, context enrichment: Grok's built-in search can pull current news directly into the agent's prompt at the moment a decision is made, transforming static technical analysis into situational awareness.

Genetic algorithms have proven their effectiveness in optimizing numerical parameters for decades. Applying the same logic to the space of trading philosophies expressed in natural language is a qualitatively different step. The market is constantly changing; it has no fixed prompt. Now your strategy doesn't have one either.

Translated from Russian by MetaQuotes Ltd.
Original article: https://www.mql5.com/ru/articles/21878

Surrogate Data Testing: A Null Model for Any Statistic, and Why Shuffling Bars Is Not One Surrogate Data Testing: A Null Model for Any Statistic, and Why Shuffling Bars Is Not One
We implement surrogate testing for indicators in MQL5: generate synthetic series that match your data's distribution and spectrum, score any statistic, and locate the observation within its null distribution. The library provides four null models, an FFT that handles arbitrary lengths, and a plug‑in interface for custom measures. An indicator visualizes the statistical alongside its chance band for direct interpretation.
Price Action Analysis Toolkit Development (Part 85): Visualizing and Reviewing Validated Double-Bottom Samples Price Action Analysis Toolkit Development (Part 85): Visualizing and Reviewing Validated Double-Bottom Samples
This part adds a visual review layer to the double-bottom workflow. It checks the chart context, moves to the recorded period, and renders the range, two qualifying lows, neckline, classification, score, and the first decisive close. The annotations mirror the validator's indices and prices, helping you audit outcomes without changing sample data.
Forecast Reconciliation (MinT): One Engine for Timeframe Hierarchies and Currency Graphs Forecast Reconciliation (MinT): One Engine for Timeframe Hierarchies and Currency Graphs
This article builds forecast reconciliation in pure MQL5 and applies it to two structures: timeframe aggregation on one symbol and a currency graph linked by no‑arbitrage. The engine projects base forecasts onto linear constraints to make them coherent. We benchmark OLS/WLS and MinT with and without shrinkage on long walk‑forward runs, show when total squared error decreases, and measure the cost that may concentrate in a single node.
Institutional-Grade Multi-Currency Portfolio Engine in MQL5 (Part 2): Instrument Universe Management and Symbol Metadata Institutional-Grade Multi-Currency Portfolio Engine in MQL5 (Part 2): Instrument Universe Management and Symbol Metadata
This article adds a symbol metadata layer to the MQL5 portfolio framework, normalizing contract specifications across instruments with different tick sizes and profit currencies. A CSymbolRegistry class loads a symbol universe from file, queries broker data, and converts tick values into account currency, including cross-rate and CFD tick/point cases. The Portfolio Controller publishes per-symbol lot limits from this metadata, and Instrument Agents recover automatically once the controller becomes available.