Most EAs blow up for the same boring reason. The entry logic was fine. Nobody was watching the account when four losers lined up back to back. During development all the energy goes into the signal, and risk gets a fixed lot size, a stop loss, and a prayer.
If you already have a working EA, or just an idea you’ve coded the entries for, you don’t have to rewrite anything. You wrap a risk layer around what’s there. Here’s how I do it in MQL5, with the patterns I actually run across the QRC strategies.
Think in layers, not in a single stop loss
A stop loss protects one trade. A risk model protects the account. Those are different jobs and you want both.
I split account-level risk into four checks that run before any new trade is allowed:
Per-trade risk decides how big the position is, based on the distance to your stop and a fixed fraction of equity. Daily loss is a hard ceiling for the day. Once you hit it, the EA stops opening trades until the next session. Total drawdown is the line you cannot cross at all, the one a prop firm fails you for. Open exposure caps how much risk you have live at once across all positions, so three correlated trades don’t quietly become one giant trade.
If you trade FTMO or any static-drawdown prop firm, the daily and total numbers aren’t suggestions. They’re the rules that end your account. Build to them first.
Step 1: size the position from risk, not from a fixed lot
Fixed lots are the single most common reason a decent strategy still fails an evaluation. A 0.50 lot trade risks a different dollar amount on XAUUSD than it does on EURUSD, and a different amount again when your stop is 200 points versus 800. Size from the stop instead.
mql5
//+------------------------------------------------------------------+
//| Calculate lot size from a fixed % risk and stop distance |
//+------------------------------------------------------------------+
double CalcLotByRisk(string symbol, double riskPercent, double stopPoints)
{
if(stopPoints <= 0) return(0.0);
double equity = AccountInfoDouble(ACCOUNT_EQUITY);
double riskMoney = equity * (riskPercent / 100.0);
double tickValue = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_VALUE);
double tickSize = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE);
double point = SymbolInfoDouble(symbol, SYMBOL_POINT);
if(tickValue <= 0 || tickSize <= 0 || point <= 0) return(0.0);
// value of one point of price movement for one lot
double pointValue = tickValue * (point / tickSize);
double lots = riskMoney / (stopPoints * pointValue);
// clamp to the broker's volume rules
double minLot = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MIN);
double maxLot = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MAX);
double lotStep = SymbolInfoDouble(symbol, SYMBOL_VOLUME_STEP);
lots = MathFloor(lots / lotStep) * lotStep;
lots = MathMax(minLot, MathMin(maxLot, lots));
return(NormalizeDouble(lots, 2));
}
The part people get wrong is pointValue. You can’t assume tick value equals point value, because on some symbols the tick size isn’t equal to one point. The tickValue * (point / tickSize) line handles that for you. Always round down to the lot step, never up, or you’ll occasionally risk slightly more than you intended.
Step 2: track the day’s starting balance
To enforce a daily loss limit you need a reference point: what the equity was when the trading day began. The trap is the rollover time. FTMO resets at midnight CE(S)T, not at your broker’s server midnight, and not at your local time. Get this wrong and your daily limit resets at the wrong hour.
Store the day’s anchor in a global variable so it survives a VPS restart or a terminal crash mid-session.
mql5
datetime g_dayStart = 0; // start time of the current trading day
double g_dayStartEq = 0.0; // equity at the start of the day
#define GV_DAY_START "QRC_DayStart"
#define GV_DAY_EQ "QRC_DayStartEq"
//+------------------------------------------------------------------+
//| Returns the broker time shifted to CE(S)T midnight boundary |
//| Adjust gmtOffsetCET to match CET (1) or CEST (2) as needed |
//+------------------------------------------------------------------+
void UpdateDayAnchor(int cetOffsetFromGMT)
{
datetime nowGMT = TimeGMT();
datetime nowCET = nowGMT + cetOffsetFromGMT * 3600;
// midnight of the CET day
MqlDateTime dt;
TimeToStruct(nowCET, dt);
dt.hour = 0; dt.min = 0; dt.sec = 0;
datetime cetMidnight = StructToTime(dt) - cetOffsetFromGMT * 3600;
if(cetMidnight != g_dayStart)
{
g_dayStart = cetMidnight;
g_dayStartEq = AccountInfoDouble(ACCOUNT_EQUITY);
GlobalVariableSet(GV_DAY_START, (double)g_dayStart);
GlobalVariableSet(GV_DAY_EQ, g_dayStartEq);
}
}
//+------------------------------------------------------------------+
//| Restore the day anchor after a restart |
//+------------------------------------------------------------------+
void RestoreDayAnchor()
{
if(GlobalVariableCheck(GV_DAY_START))
g_dayStart = (datetime)GlobalVariableGet(GV_DAY_START);
if(GlobalVariableCheck(GV_DAY_EQ))
g_dayStartEq = GlobalVariableGet(GV_DAY_EQ);
}
Call RestoreDayAnchor() once in OnInit() and UpdateDayAnchor() at the top of OnTick(). Now the day boundary is correct and it survives a crash.
Step 3: the gate that runs before every trade
This is the heart of it. One function the EA must call and pass before it sends an order. If any check fails, no trade. I run it as a hard gate so a bug in the entry logic can’t bypass it.
mql5
input double InpRiskPerTrade = 0.5; // % equity risked per trade
input double InpMaxDailyLoss = 4.0; // % daily loss limit (soft buffer under FTMO's 5)
input double InpMaxTotalDD = 8.0; // % total drawdown limit (buffer under FTMO's 10)
input double InpMaxOpenRisk = 2.0; // % max combined open risk
double g_initialBalance = 0.0; // baseline for total DD, set once
//+------------------------------------------------------------------+
//| Master risk gate. Returns true if a new trade is allowed. |
//+------------------------------------------------------------------+
bool RiskGatePassed()
{
double equity = AccountInfoDouble(ACCOUNT_EQUITY);
// --- total drawdown check (the account-ending one) ---
double totalDDpct = (g_initialBalance - equity) / g_initialBalance * 100.0;
if(totalDDpct >= InpMaxTotalDD)
{
Print("RISK HALT: total DD ", DoubleToString(totalDDpct,2), "% >= ", InpMaxTotalDD);
return(false);
}
// --- daily loss check ---
double dayLossPct = (g_dayStartEq - equity) / g_dayStartEq * 100.0;
if(dayLossPct >= InpMaxDailyLoss)
{
Print("RISK HALT: daily loss ", DoubleToString(dayLossPct,2), "% >= ", InpMaxDailyLoss);
return(false);
}
// --- combined open risk check ---
if(CurrentOpenRiskPercent() >= InpMaxOpenRisk)
{
Print("RISK HALT: open risk at limit");
return(false);
}
return(true);
}
Two things worth saying here. First, set your internal limits below the prop firm’s published numbers. FTMO’s daily limit might be 5 percent, but you stop at 4. The gap is your buffer against slippage, a gap fill, or a spread blowout at news. Hitting your own 4 percent stop is a normal Tuesday. Hitting their 5 percent stop loses the account.
Second, g_initialBalance is the baseline for total drawdown, and on a static-drawdown account it never moves up with your profits. Set it once from the starting balance and leave it. If you let it trail your equity you’ve quietly turned a static limit into a trailing one and you’ll fail in a way that’s hard to debug.
Step 4: measure open risk honestly
The open-risk check above needs a function that adds up what you actually stand to lose if every open position hits its stop right now. Not the notional size, the real money at risk.
mql5
//+------------------------------------------------------------------+
//| Sum the money at risk across all open positions, as % of equity |
//+------------------------------------------------------------------+
double CurrentOpenRiskPercent()
{
double equity = AccountInfoDouble(ACCOUNT_EQUITY);
if(equity <= 0) return(100.0);
double riskMoney = 0.0;
for(int i = PositionsTotal() - 1; i >= 0; i--)
{
ulong ticket = PositionGetTicket(i);
if(ticket == 0) continue;
if(!PositionSelectByTicket(ticket)) continue;
double sl = PositionGetDouble(POSITION_SL);
if(sl == 0.0) continue; // no stop = treat separately, see note below
string sym = PositionGetString(POSITION_SYMBOL);
double open = PositionGetDouble(POSITION_PRICE_OPEN);
double vol = PositionGetDouble(POSITION_VOLUME);
long type = PositionGetInteger(POSITION_TYPE);
double point = SymbolInfoDouble(sym, SYMBOL_POINT);
double tickValue = SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_VALUE);
double tickSize = SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_SIZE);
if(point <= 0 || tickSize <= 0) continue;
double pointValue = tickValue * (point / tickSize);
double stopPoints = (type == POSITION_TYPE_BUY)
? (open - sl) / point
: (sl - open) / point;
if(stopPoints < 0) stopPoints = 0; // already past the stop
riskMoney += stopPoints * pointValue * vol;
}
return(riskMoney / equity * 100.0);
}
A position with no stop loss is infinite risk by this measure, which is exactly the problem. If your strategy runs naked stops at any point, you need a separate worst-case rule for those, otherwise this function silently undercounts the danger. Most prop accounts I’d argue shouldn’t run without stops at all.
Step 5: wire it into the EA you already have
You don’t touch the entry logic. You add three call sites.
In OnInit(), set the baseline and restore state:
mql5
int OnInit()
{
if(GlobalVariableCheck("QRC_InitBalance"))
g_initialBalance = GlobalVariableGet("QRC_InitBalance");
else
{
g_initialBalance = AccountInfoDouble(ACCOUNT_BALANCE);
GlobalVariableSet("QRC_InitBalance", g_initialBalance);
}
RestoreDayAnchor();
return(INIT_SUCCEEDED);
}
At the top of OnTick(), refresh the day anchor:
mql5
void OnTick()
{
UpdateDayAnchor(2); // 2 = CEST; use 1 for CET in winter
// ... your existing signal logic produces a buy/sell decision ...
}
And right before you send the order, gate it and size it:
mql5
if(buySignal)
{
if(!RiskGatePassed())
return; // risk layer says no, so we don't trade
double stopPoints = MathAbs(entryPrice - stopPrice) / _Point;
double lots = CalcLotByRisk(_Symbol, InpRiskPerTrade, stopPoints);
if(lots <= 0)
return; // can't size it safely, skip
trade.Buy(lots, _Symbol, 0, stopPrice, takeProfit);
}
That’s the whole integration. The signal logic decides what to trade. The risk layer decides whether you’re allowed to, and how much.
The two-tier halt, for when things go really wrong
There’s a difference between “stop opening new trades” and “get me flat right now.” A soft halt stops new entries when you near the daily limit. A hard halt closes everything when you’re about to breach the account-ending limit, because at that point an open position is a liability you can’t afford to leave running.
mql5
//+------------------------------------------------------------------+
//| Two-tier protection. Call once per tick after UpdateDayAnchor. |
//+------------------------------------------------------------------+
void CheckHardStop()
{
double equity = AccountInfoDouble(ACCOUNT_EQUITY);
double totalDDpct = (g_initialBalance - equity) / g_initialBalance * 100.0;
// hard stop fires slightly before the real limit
if(totalDDpct >= InpMaxTotalDD - 0.5)
{
CloseAllPositions();
Print("HARD STOP: flattening at ", DoubleToString(totalDDpct,2), "% DD");
}
}
//+------------------------------------------------------------------+
//| Close everything, retrying until flat |
//+------------------------------------------------------------------+
void CloseAllPositions()
{
for(int attempt = 0; attempt < 5; attempt++)
{
bool allClosed = true;
for(int i = PositionsTotal() - 1; i >= 0; i--)
{
ulong ticket = PositionGetTicket(i);
if(ticket == 0) continue;
if(PositionSelectByTicket(ticket))
{
if(!trade.PositionClose(ticket))
allClosed = false;
}
}
if(allClosed) break;
Sleep(200); // let the server catch up, then retry stragglers
}
}
The retry loop matters more than it looks. A single close attempt can fail on a requote or a busy server, and “I tried once to close and moved on” is how you end up still holding a position you thought was gone. Loop until flat or you’ve genuinely exhausted your attempts.
Set the fill type or your closes will reject
One last thing that bites people on certain brokers. If you don’t set the order filling mode to match what the symbol supports, your close orders get rejected and the EA thinks it’s flat when it isn’t. Set it per symbol.
mql5
void SetFillingForSymbol(string symbol, CTrade &t)
{
long filling = SymbolInfoInteger(symbol, SYMBOL_FILLING_MODE);
if((filling & SYMBOL_FILLING_FOK) == SYMBOL_FILLING_FOK)
t.SetTypeFilling(ORDER_FILLING_FOK);
else if((filling & SYMBOL_FILLING_IOC) == SYMBOL_FILLING_IOC)
t.SetTypeFilling(ORDER_FILLING_IOC);
else
t.SetTypeFilling(ORDER_FILLING_RETURN);
}
Call it once per symbol in OnInit() after you’ve selected your trading instruments.
How to test it before you trust it
Code that’s never been stressed isn’t a risk model, it’s a hope. Before this goes near a funded account:
Run the strategy tester on a period that includes a genuinely bad stretch, not a calm uptrend. You want to see the daily and total limits actually fire. If they never trigger across months of data, either your strategy is suspiciously perfect or your gate isn’t wired in.
Force the failure on a demo. Crank the risk per trade up high temporarily and watch the soft halt, then the hard halt, kick in at the right levels. Then put the risk back where it belongs.
Restart the terminal mid-trade on demo and confirm the day anchor and baseline restore correctly from the global variables. This is the test almost nobody runs, and it’s the one that catches the bug where a VPS reboot wipes your daily reference and the limit silently resets.
The short version
The entry logic answers “should I trade.” The risk model answers “can I afford to, and how much.” If you only build the first half, the market eventually builds the second half for you, and it does it the expensive way.
Wrap the layer, set your internal limits below the prop firm’s, make the hard stop actually flatten, and test it against a bad month before a real one shows up
