MQL5 done right: the mistakes that cost real money

MQL5 is a complete C-like language, with OOP, and an environment — MetaTrader 5 — full of specific behaviors no generalist manual covers. This is not a course: it's the reasoned list of the places where I've seen the most money die, in my code and in code I review. If you use AI to generate EAs, this list is also your review checklist: these are exactly the spots where generated code fails most often.

1 · New bar vs every tick

OnTick fires on every price update; most bar-based logic must instead act once per bar. The canonical check — comparing the current bar's timestamp with the last seen — must sit at the top, and the logic must read the closed bar (index 1), not the forming one (index 0). Reading index 0 in an EA that decides "at candle close" is live look-ahead: it produces diverging backtests and reality.

2 · Money and volumes are not compared with ==

Every size must be normalized to SYMBOL_VOLUME_STEP and clamped between SYMBOL_VOLUME_MIN and SYMBOL_VOLUME_MAX; every price normalized to ticks. The classic prop account burned by a badly rounded 0.014999 lot is not a legend. (It's also the problem FastAutolot exists to eliminate: sizing computed by the system, not by fingers.)

3 · Orders fail, and that's normal

OrderSend returns outcomes that must be read: requote, off quotes, no money, trade context busy, market closed. A production EA distinguishes transient errors (retry with backoff, few times, with logging) from final ones (stop and notify). AI-generated code, by default, either ignores the retcode or retries forever: both versions, on a real account, have already cost someone dearly.

4 · The true state lives on the server

After an order, the truth is in the resulting positions and orders, not in the local variable g_iHaveAPosition. You need periodic local-state/server-state reconciliation, and state reconstruction in OnInit — the EA can restart with positions open (crash, VPS reboot, recompile). It's what separates systems that survive a blackout from systems that open the double position on restart.

5 · OnTradeTransaction for events, not polling

Executions, SL/TP closes, modifications: they arrive as asynchronous transactions. Handling them in OnTradeTransaction — filtered by magic number — is more reliable than polling history every tick. But the function must be written knowing transactions can arrive in non-obvious orders and duplicate in edge cases: idempotency — every handler must be able to process the same event twice without damage.

6 · The tester lies about the details

"Every tick based on real ticks" is the bare minimum; even so, historical spread may be reconstructed, slippage doesn't exist, Wednesday triple swaps on metals and liquidity interruptions must be checked by hand. An MT5 backtest is an upper bound of reality: treat it as such when sizing your expectations.

7 · Magic numbers and coexistence

Every system marks its own orders with a unique magic and religiously ignores everything else. Sounds trivial; half of the "the EA closed my manual trades" disasters are born here. On a mixed account, magic numbers are also what makes separate post-hoc analysis of each component possible.

The skeleton I reuse

int OnInit()
{
   if(!ValidateInputs())  return INIT_PARAMETERS_INCORRECT; // no silent defaults
   if(!ReconcileState())  return INIT_FAILED;   // restarting with open positions?
   g_dayStartEquity = AccountInfoDouble(ACCOUNT_EQUITY);
   g_peakEquity     = MathMax(g_peakEquity, g_dayStartEquity); // peak survives restarts
   return INIT_SUCCEEDED;
}

void OnTick()
{
   if(!CheckRiskGuardian()) return;  // the guardian rules over everything
   if(!IsNewBar())          return;  // closed-bar logic
   if(!FiltersPass())       return;  // vetoes: direction, hours, day, spread, cooldown
   Signal s = DetectSignal();        // geometry only, no orders
   if(s.valid) Execute(s);           // sizing + send with error handling
}

void OnTradeTransaction(const MqlTradeTransaction &t,
                        const MqlTradeRequest &req,
                        const MqlTradeResult &res)
{
   if(!IsMine(t)) return;            // magic filter: the rest of the account doesn't exist
   UpdateStateIdempotent(t);         // same event twice = zero damage
}

And the guardian — the fifth module, transversal and non-negotiable — with the detail that separates a limit from a suggestion:

// Circuit breaker with latch: once triggered, stays triggered.
bool CheckRiskGuardian()
{
   if(g_latched) return false;                       // already stopped: stay stopped
   double eq = AccountInfoDouble(ACCOUNT_EQUITY);    // EQUITY, never balance
   if(InpUseDailyLossLimit && eq <= g_dayStartEquity*(1.0-InpDailyLossPct/100.0))
      { g_latched=true; CloseAllPositions(); return false; }
   if(InpUseMaxDDLimit && eq <= g_peakEquity*(1.0-InpMaxDDPct/100.0))
      { g_latched=true; CloseAllPositions(); return false; }
   if(eq > g_peakEquity) g_peakEquity = eq;
   return true;
}

Three production details, learned the expensive way: the latch (g_latched) is the difference between a limit and a suggestion; the check runs on equity, not balance (live risk is in the open positions); and the guardian closes everything, not just blocks new entries — a drawdown limit that lets open positions run is not a limit.

The principle behind it all: a mature EA's code is the transcription of its risk doctrine. If you can't find expectancy, costs, drawdown and sizing as explicit modules in the code, you don't have a trading system: you have an order generator with optimism hard-coded.

How I use AI to write and review this code without getting hurt: the complete method with the prompts.