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.
How I use AI to write and review this code without getting hurt: the complete method with the prompts.