Why My MQL5 EA Passed Validation and Still Failed Under Execution Delay
Contents
- Introduction
- Build history and test conditions
- Failure 1: a forced close inside the freeze level
- Failure 2: stops computed from an old price
- Failure 3: the wrong position and an unprotected trade
- Failure 4: repeated requests and the cooldown
- Results
- What to fix before using this code
- Limitations
- Conclusion
- References
- Appendix A: tester settings
- Appendix B: full result tables
- Appendix C: helper code
- Attached files
Introduction
On 9 September 2026 I submitted a tick scalper to CodeBase five times in about half an hour. The first four submissions came back "Validation completed with errors", each with 21 errors, and the fifth passed. Only one report still shows its log. It says the test ran on EURUSD H1, while the EA is written for M1. That detail sent me looking for a timeframe bug that did not exist. The log lines themselves showed a forced close rejected because the position was too close to the market.
The real surprise came after the pass. When I rebuilt the test in my own Strategy Tester with execution delay, the published version still produced rejected stop modifications in every run. On a custom symbol, one position opened just before the weekend close started a chain of modifications aimed at the wrong position.
The main conclusion of this article is short. Passing validation did not show that the trading logic behaves correctly under execution delay. With a two-step entry, the question that matters is whether the EA keeps control of the position when a modify or a close fails or arrives late. In the builds tested here, it did not always keep that control.
Build history and test conditions
My five submissions were spread across two CodeBase entries, because I started a new entry after the second failure. I matched the drafts to my local source files by their save times.
| Submission | Time | What the code did | Result |
|---|---|---|---|
| 1 | 18:12 | Forced exit after 90 seconds, retried every 5 seconds, three attempts | 21 errors |
| 2 | 18:17 | Same, plus a check for price at or past the position's own stop | 21 errors |
| 3 | 18:30 | Forced exit still present | 21 errors |
| 4 | 18:37 | Forced exit removed, stops still computed from a price snapshot | 21 errors |
| 5 | 18:43 | Stops attached after the fill, from the real open price | 0 errors |
Four builds carry the main tests:
| Name in this article | What it is |
|---|---|
| First version | The build before submission 1. ATR-based burst signal, timed exit with no retry limit, and stops sent with the order |
| Guarded version | Submission 2. A new z-score burst signal, timed exit with a retry cap and a stop-level check, and stops sent with the order |
| Published version 1.00 | Submission 5, unchanged. Same signal as the guarded version, no timed exit, stops attached after the fill, a safety close, and a distance floor |
| Version 1.10 | Written during this investigation. The published logic plus five changes: a burst-based stop distance, the spread in the distance floor, a session-close buffer, a deal-based position lookup, and the cooldown moved to after the send |
Two points about this history matter for everything below. The first version was never submitted for validation. And each build differs from the one before it in more than one place. So comparing historical builds shows which kinds of error each one produced, but it can't show which single change removed an error. A separate ablation build answers that question in the Results section.
Only the report for submission 2 still shows its error lines. Submission 5 passed after the stops were moved to after the fill. That is a timeline, not proof that this change caused the pass. The causes I give for each failure come from my local runs, not from the validator.
What is known from the report and how the errors were reproduced
The report mixes two kinds of information. Its first line reads "test on EURUSD,H1 (netting)" and names the environment. It tells you where the robot was run, and nothing about whether it works there. The trade lines below it were the useful part. They are execution problems, and they can happen on M1 as well as on H1. The report doesn't show the tick model, the delay setting, or the full symbol specification. Its frozen-position messages show that a freeze level was in play.
MetaQuotes describes the margin, volume, stop distance, and freeze level checks in its Market validation article [1]. My submissions went to CodeBase, so I use that article only to explain the trading restrictions. One point from it matters here. A request can pass a distance check in the EA and still be rejected at the server, because the price has moved in between.
During development I had used M1, real ticks, and no delay. For the reproduction I added execution delay, so that time passes between each request and its result. Random delay served to find the errors. The MetaTrader 5 Help describes it as 0 to 8 seconds in 90% of cases and 9 to 18 seconds in the other 10% [8]. Its sequence can't be fixed or recorded, so the counts move between runs, and each later build got five runs. A fixed delay of 5,000 ms served for the controlled comparison, because it gives the same result every time. The published version gave 37 rejected modifies in each of three such runs.
The plain EURUSD runs on MetaQuotes-Demo produced no frozen-position rejection. So I built EURUSD_FZ, a custom symbol [7] with a copy of the EURUSD M1 history and a freeze level of 20 points. The validator's own value isn't shown anywhere, so the 20 points are my choice. The script MakeFreezeSymbol.mq5 creates the symbol. Counts on EURUSD_FZ show the class of an error. They don't show what the validator would report.
| Condition | Validator, from the surviving report | Local runs |
|---|---|---|
| Symbol and timeframe | EURUSD H1 | EURUSD and EURUSD_FZ, H1 |
| Account | Netting | Hedging, MetaQuotes-Demo |
| Tick model | Unknown | Every tick, generated from the M1 history |
| Delay | Unknown | Random delay to find errors, a fixed delay of 5,000 ms for controlled comparison |
| Freeze level | Present, value unknown | EURUSD_FZ: 20 points, my choice |
| Period | April 2023 | EURUSD 2026.01.01 to 2026.09.20, EURUSD_FZ 2026.06.16 to 2026.09.20 |
| Terminal | Unknown | Build 6204 for random delay, build 6252 for fixed delay |
The full settings, the exported ini file, and the spread figures are in Appendix A.
Failure 1: a forced close inside the freeze level
The EA originally closed a trade by hand once it had been open longer than InpMaxHoldSeconds. The intent was reasonable: a scalper should not sit in a stagnant position for an hour. The failure developed in five steps.
The timer sends the close. The first version did it like this:
if(PositionSelectByTicket(g_activeTicket)) { datetime openTime = (datetime)PositionGetInteger(POSITION_TIME); if(TimeCurrent() - openTime >= InpMaxHoldSeconds) trade.PositionClose(g_activeTicket); return; }
The freeze level forbids it. For a position with SL/TP, the freeze distance is measured from the price the position would close at, Bid for a buy and Ask for a sell [1]. Inside that distance the server refuses a modify or a close. The surviving report shows the same close rejected three times, five seconds apart:
test on EURUSD,H1 (netting) 2023.04.03 01:01:47 failed market buy 0.45 EURUSD, close #4 sell 0.45 EURUSD 1.08398 [Modification failed due to order or position being close to market] 2023.04.03 01:01:52 failed market buy 0.45 EURUSD, close #4 sell 0.45 EURUSD 1.08398 [Modification failed due to order or position being close to market] 2023.04.03 01:01:57 failed market buy 0.45 EURUSD, close #4 sell 0.45 EURUSD 1.08398 [Modification failed due to order or position being close to market]
The return code behind that message is TRADE_RETCODE_FROZEN, 10029 [4]. On EURUSD_FZ the first version printed the same message 749 times. On plain EURUSD the same code failed differently. The close was still in flight when the position's own stop loss fired, so it arrived for a position that no longer existed:
2026.02.13 15:36:34 stop loss triggered #353 buy 1.22 EURUSD 1.18758 sl: 1.18714 tp: 1.18786 [#354 sell 1.22 EURUSD at 1.18714] 2026.02.13 15:36:36 failed market sell 1.22 EURUSD [Position doesn't exist]
That close returned TRADE_RETCODE_POSITION_CLOSED, 10036, nine times in the first version's EURUSD run.
Checking "price has reached SL/TP" doesn't help. The guarded version (submission 2) skipped the close when price had already reached the position's own stop or target:
if(holdTimeUp && IsAtOrPastOwnStopLevel()) { ... return; }
This check tests the wrong edge. The freeze level is a band before the stop. With a freeze level of 20 points, price that is 10 points short of the take profit is not "at or past" it, and it is already inside the band. On EURUSD_FZ the guarded version still produced 1,958 rejections, and its own journal names the code:
2026.06.23 04:53:03 Close attempt 1/3 failed: 10029 frozen 2026.06.23 04:53:07 Close attempt 2/3 failed: 10029 frozen
The retry cap limits the requests, not the cause. The report above comes from the guarded version. It shows the cap working: three attempts on position #4, then nothing more for that position. The errors were still there. A better pre-check would read SYMBOL_TRADE_FREEZE_LEVEL and measure the distance to both levels. However, it would still have to handle a rejection, because the quote can move between the check and the request.
Removing the timer removes this failure path. Submission 4 dropped the timed exit, and the published version kept it that way:
if(PositionSelectByTicket(g_activeTicket)) return; // still open, wait for its own SL/TP
That stopped the timed-close rejections. The cost is the hold-time limit. A stagnant trade now stays open until SL or TP. The branch is also safe only once the stops are on the position. The next failures show what happens when they are not.
Failure 2: stops computed from an old price
Submission 4 still failed with the forced exit gone. Its report no longer shows the error lines, so this part describes what the code did and what my rerun shows. The entry code read the current price and computed both levels from it:
double price = wantBuy ? SymbolInfoDouble(_Symbol, SYMBOL_ASK) : SymbolInfoDouble(_Symbol, SYMBOL_BID); double sl = wantBuy ? price - slDist : price + slDist; double tp = wantBuy ? price + tpDist : price - tpDist;
The value of price is a snapshot. By the time the request reaches the server the market has moved, and the server checks the stops against the price at execution. If one level ends up on the wrong side, the whole order is rejected with TRADE_RETCODE_INVALID_STOPS, 10016. The first version did this 82 times in 1,064 trades in my EURUSD rerun.
The published version opened without SL/TP and then attached the levels from the actual entry price:
bool sent = wantBuy ? trade.Buy(lot, _Symbol, 0.0, 0.0, 0.0, "PulseStrike") : trade.Sell(lot, _Symbol, 0.0, 0.0, 0.0, "PulseStrike");
if(!sent) { PrintFormat("Order failed: %d %s", trade.ResultRetcode(), trade.ResultRetcodeDescription()); return; } if(!PositionSelect(_Symbol)) { Print("Order sent but the resulting position could not be found."); return; } ulong newTicket = (ulong)PositionGetInteger(POSITION_TICKET); double actualOpenPrice = PositionGetDouble(POSITION_PRICE_OPEN); double sl = wantBuy ? actualOpenPrice - slDist : actualOpenPrice + slDist; double tp = wantBuy ? actualOpenPrice + tpDist : actualOpenPrice - tpDist;
This removed the entry-stop rejections in my runs, but it moved the same problem to the modify. PositionModify is a second trade request. It goes through the same delay, and the server checks the new levels against the price when the modify arrives. On EURUSD with random delay, the published version printed this 39, 40, 27, 29, and 35 times in five runs:
2026.01.09 15:32:26 CTrade::OrderSend: market buy 1.30 EURUSD [done at 1.16463] 2026.01.09 15:32:32 failed modify #536 buy 1.3 EURUSD sl: 0.00000, tp: 0.00000 -> sl: 1.16423, tp: 1.16488 [Invalid stops] 2026.01.09 15:32:35 market sell 1.3 EURUSD, close #536 (1.16508 / 1.16509 / 1.16508)
The buy filled at 1.16463 and asked for a TP at 1.16488. The modify was rejected six seconds later. Three seconds after that, the Bid used for the safety close was 1.16508, already above the requested target. This fits price running through the TP level while the modify was in flight. However, a later quote can't prove the price at the exact moment of rejection.
Version 1.10 widened the distances by a multiple of the burst that triggered the entry:
double burstDist = MathAbs(currentMove) * InpBurstStopMult; slDist = MathMax(slDist, burstDist); tpDist = MathMax(tpDist, burstDist);
The lot size is calculated from the widened slDist, so the planned risk stays about the same, apart from volume rounding. In five runs per build, the mean count of rejected modifies fell from 34.0 to 16.2. Five changes separate the two builds, so this drop alone can't be assigned to the wider distances. The ablation in the Results section does that.
Both builds also apply a distance floor built from the stops level, the freeze level, and the spread (the function is in Appendix C):
double minStopDist = GetMinStopDistance(_Symbol) * 1.5; slDist = MathMax(slDist, minStopDist); tpDist = MathMax(tpDist, minStopDist);
The floor only plans the distances. For a buy filled at Ask, the stop loss ends up closer to Bid than the entry distance suggests, by the spread. This is why the spread is added. Each final price still has to be rounded to SYMBOL_TRADE_TICK_SIZE and checked against a fresh quote [2].
Failure 3: the wrong position and an unprotected trade
Splitting the order into two steps brings a risk the single-step version didn't have. Between the two requests the position exists and has no stop. If the second request fails, it stays that way. This part shows how that happened and how it led to stops being sent to the wrong position.
The position left without a stop
To see what an unmanaged position costs, I made a diagnostic copy of the published version, with the safety close and the distance floor removed. Two safeguards changed at once, so this run doesn't measure either of them on its own. I haven't attached the copy, since it exists only for this demonstration. Its journal contains:
2026.01.09 15:30:34 failed modify #518 sell 1.34 EURUSD sl: 0.00000, tp: 0.00000 -> sl: 1.16413, tp: 1.16348 [Invalid stops] 2026.01.09 15:30:34 Failed to attach SL/TP (10016 invalid stops), LEAVING IT UNPROTECTED (test build). 2026.09.18 23:59:59 position closed due end of test at 1.14866 [#518 sell 1.34 EURUSD 1.16373]
The last line is MT5's own wording. One rejected modify on 9 January left the position open until the tester closed it on 18 September. The report shows a maximal equity drawdown of 58.86% against a maximal balance drawdown of 5.75%. The run still finished USD 2,387.26 ahead, only because EURUSD ended below the entry of that one sell. Fig. 1 comes from a second run of the same copy. Its equity line is rebuilt from the report's deals and the EURUSD H1 bars, so it can't show moves inside an hour.
Unprotected sell: balance and equity, EURUSD H1, 2026
Second run of the diagnostic copy (safety close and distance floor removed), equity rebuilt from the report's deals and EURUSD H1 bars

Fig. 1. Balance and equity of the second run of the diagnostic copy. The balance goes flat after the rejected modify on 9 January, while the equity follows the open sell until the tester closes it on 18 September
The published version answers a failed modify with an attempt to close. The same branch in version 1.10:
if(!trade.PositionModify(newTicket, sl, tp)) { //--- The stops could not be attached, so try to close the position. //--- Known gap: if this close fails too, the position is never stored //--- in g_activeTicket and the EA stops tracking it (article, Failure 3). PrintFormat("Failed to attach SL/TP (%d %s), closing the position for safety.", trade.ResultRetcode(), trade.ResultRetcodeDescription()); if(!trade.PositionClose(newTicket)) Print("WARNING: could not close the unprotected position either."); return; }
When the close fails too, this branch returns and the position is lost from tracking. It happened once in the EURUSD_FZ run of the published version:
2026.07.24 23:59:54 CTrade::OrderSend: market sell 0.59 EURUSD_FZ [done at 1.13697] 2026.07.25 00:00:02 failed modify #1972 sell 0.59 EURUSD_FZ sl: 0.00000, tp: 0.00000 -> sl: 1.13754, tp: 1.13661 [Market closed] 2026.07.25 00:00:06 failed market buy 0.59 EURUSD_FZ, close #1972 sell 0.59 EURUSD_FZ 1.13697 [Market closed] 2026.07.25 00:00:06 WARNING: could not close the unprotected position either.
The entry filled six seconds before the market closed for the weekend. The modify and the safety close both arrived after the close and were rejected with TRADE_RETCODE_MARKET_CLOSED, 10018. Position #1972 sat without a stop until Monday. An entry that takes two requests, sent a few seconds before the close, can do the same on a real account.
Version 1.10 stops opening trades within five minutes of the end of the current trade session. It reads the sessions with SymbolInfoSessionTrade [2]. This covered the weekend case in my runs. However, the check knows only the weekly timetable, so an early holiday close would pass it.
When protection is sent to the wrong position
The published version finds the new position with PositionSelect(_Symbol). This asks for a position on the symbol, not for the one this order opened. On a hedging account with more than one position on the symbol, it can return the wrong one. With #1972 still open, the next entry on Monday looked like this:
2026.07.27 00:05:18 CTrade::OrderSend: market sell 0.60 EURUSD_FZ [done at 1.13932] 2026.07.27 00:05:18 failed modify #1972 sell 0.59 EURUSD_FZ sl: 0.00000, tp: 0.00000 -> sl: 1.13753, tp: 1.13662 [Invalid stops] 2026.07.27 00:05:25 CTrade::OrderSend: market buy 0.59 position #1972 EURUSD_FZ [done at 1.13944]
The order was a 0.60-lot sell. The modify and the safety close both name #1972, the 0.59-lot sell from Friday. The requested levels sit 56 points above and 35 points below the open price of #1972, 1.13697, so they were computed from that position. The safety close then closed #1972. These lines don't print the ticket of the new position, and only an extract of the run's error lines still exists.
The extract allows one check that needs no ticket numbers. The EA puts the stop loss below the take profit for a buy and above it for a sell. If a rejected modify has them the other way round, its levels were computed for a position of the opposite direction, so the modify went to a position other than the one the order opened. All 11 rejected modifies before 25 July have the levels the right way round. Of the 987 from 25 July on, 776 have them reversed, and these run until 18 September. The other 211 can't be classified from the extract. In the published code the failure branch returns before it stores the new ticket. So in each of those 776 cases, the position the order had just opened was left without a stop, and the EA wasn't tracking it.
Version 1.10 reads the position from the deal the order produced. It still kept a symbol-only fallback, so one route to the wrong position stayed open:
long newPosId = 0; ulong dealTicket = trade.ResultDeal(); if(dealTicket > 0 && HistoryDealSelect(dealTicket)) newPosId = HistoryDealGetInteger(dealTicket, DEAL_POSITION_ID); ulong newTicket = 0; if(!SelectPositionById(newPosId, newTicket)) { if(!PositionSelect(_Symbol)) { Print("Order sent but the resulting position could not be found."); return; } newTicket = (ulong)PositionGetInteger(POSITION_TICKET); newPosId = PositionGetInteger(POSITION_IDENTIFIER); }
DEAL_POSITION_ID gives the position the deal belongs to [5]. A position has two numbers. PositionModify() and PositionClose() expect POSITION_TICKET, while HistorySelectByPosition() expects POSITION_IDENTIFIER [6]. They usually hold the same value, so passing one where the other belongs looks like working code. The ticket can change after a service operation on the position, while the identifier stays the same for its life. MQL5 has no function that selects a position by identifier, so SelectPositionById() walks the open list (Appendix C).
The deal route has limits of its own. ResultDeal() can return 0 for an accepted request, one order can be filled by several deals, and a deal can reach the history after the call returns. I didn't measure these cases, so the lookup has only been exercised on simple fills.
Failure 4: repeated requests and the cooldown
The last problem is a small one, but it made the other three show up more often. The entry cooldown, g_lastEntryTime, was updated only inside the success branch. A rejected order left it untouched, so on the next tick the same signal was still valid and the EA sent the same order again. In the first version's EURUSD rerun, the 82 invalid stops rejections came in groups. On 23 March there were five between 13:05:11 and 13:05:43. Each retry was a fresh request during the same fast move, so it failed for the same reason.
Version 1.10 starts the cooldown right after the send, whether the send succeeded or not:
bool sent = wantBuy ? trade.Buy(lot, _Symbol, 0.0, 0.0, 0.0, "ZetaBurst") : trade.Sell(lot, _Symbol, 0.0, 0.0, 0.0, "ZetaBurst"); ... g_lastEntryTime = TimeCurrent();
One bad setup now costs one request per cooldown instead of one per tick. The change doesn't use up the signal, and it doesn't tell the EA whether the order filled. So it lowers the retry rate, but it is not a fix for duplicate requests.
Results
The changes reduced the number of failures, but they did not remove the main defect. After a failed modify and a failed close, the EA can still lose control of an open position.
The historical builds give the following counts. The first and guarded versions have one run each. The published version and version 1.10 have five random-delay runs on EURUSD and one run on EURUSD_FZ. "n/a" means the build never sends that request.
| Error | First version | Guarded version | Published version | Version 1.10 |
|---|---|---|---|---|
| EURUSD: order rejected, invalid stops (10016) | 82 | 13 | 0 | 0 |
| EURUSD: close rejected, position already closed (10036) | 9 | 31 | 0 | 0 |
| EURUSD: modify rejected, invalid stops (10016), range of five runs | n/a | n/a | 27 to 40 | 10 to 25 |
| EURUSD_FZ: close rejected inside the freeze level (10029) | 749 | 1,958 | 0 | 0 |
| EURUSD_FZ: modify rejected, invalid stops (10016) | n/a | n/a | 998 | 8 |
| EURUSD_FZ: market closed (10018) | 0 | 0 | 2 | 0 |
These are debugging comparisons, not controlled experiments, because the entry signal and several other changes separate the builds. Of the published version's 998 rejected modifies on EURUSD_FZ, 987 came after the weekend incident, and at least 776 of those went to the wrong position. Trade counts, rates per 1,000 trades, and the full random-delay runs are in Appendix B.
Ablation: one change at a time
To measure each change of version 1.10 on its own, I wrote ZetaBurst_Ablation.mq5. Its trading code is the published version, and each of the five changes sits behind its own input. With every switch off it gave the same trades, rejections, and net result as the published file on both symbols. With every switch on it matched version 1.10 in the same way. All runs use the fixed delay of 5,000 ms and build 6252. Each middle row adds one change to the published version:
| Build | EURUSD: modify rejected (10016) | EURUSD: trades | EURUSD_FZ: modify rejected (10016) | EURUSD_FZ: trades |
|---|---|---|---|---|
| Published version 1.00, every switch off | 37 | 8,174 | 9 | 2,220 |
| Plus the burst-based distance, multiplier 2.0 | 6 | 7,776 | 1 | 2,083 |
| Plus the spread in the distance floor | 37 | 8,174 | 6 | 1,965 |
| Plus the session-close buffer, 5 minutes | 37 | 8,166 | 9 | 2,221 |
| Plus the deal-based lookup | 37 | 8,174 | 9 | 2,220 |
| Plus the cooldown after the send | 30 | 8,162 | 7 | 2,210 |
| All five changes, same as version 1.10 | 4 | 7,812 | 0 | 1,866 |
Under this delay, the burst-based distance accounts for most of the drop. On EURUSD it took the rejected modifies from 37 to 6 on its own. The cooldown change removed seven. On EURUSD the distance floor never decided a distance, so adding the spread changed nothing there. On EURUSD_FZ the floor includes the 20-point freeze level, and the spread lowered the rejections from 9 to 6. The effects are not additive.
The session buffer and the deal-based lookup made no difference in any fixed-delay run. Both are aimed at the weekend case, and that case didn't recur. I also ran EURUSD_FZ at fixed delays of 8,000 and 12,000 ms. In addition, I made 10 random-delay runs with and without the session buffer. None produced a market-closed rejection or a modify aimed at another position. So the effect of these two changes was not measured, and their value rests on the single weekend case.
Sensitivity of the chosen values
The 20-point freeze level, the floor multiplier of 1.5, the burst multiplier of 2.0, and the five-minute session buffer are my own choices. I reran the ablation build with all changes enabled and varied one value at a time (full table in Appendix B). The freeze level changed the counts, not the kind of error. The burst multiplier is a tradeoff: on EURUSD, 1.0 left 16 rejected modifies and 3.0 left one, but 3.0 cost 8.2% of the trades. The delay itself had a larger effect than any of these values. On EURUSD_FZ the published version gave 9, 12, and 14 rejected modifies at fixed delays of 5,000, 8,000, and 12,000 ms. Version 1.10 gave 0, 5, and 7 at the same delays.
Auditing unprotected positions
The debugging runs count journal lines. They don't check each position for SL and TP. The ablation build adds a read-only audit for this. On every tick it reads all open positions with the EA's magic number. A position with neither SL nor TP for longer than 60 seconds counts as unprotected. It also compares the position each modify was sent to with the position named by the order's deal.
In all 46 runs of the ablation build, with fixed and random delays, no position stayed without stops for longer than 60 seconds. No modify went to another position, and no safety close failed. The window from the fill to the attached stops averaged 4.59 to 4.74 seconds at a delay of 5,000 ms, and the longest in any run was 25 seconds. The audit has limits. It exists only in the ablation build, so the 776 unprotected positions in the published version's EURUSD_FZ run rest on the log extract from Failure 3. It reads positions once per tick, and a position with only one of SL or TP counts as protected.
Every build except the first lost most of the deposit in these H1 runs. These runs reproduce execution errors. They don't evaluate the strategy.
What to fix before using this code
The historical builds are what the tables measure, so I left their logic alone. Before reusing these snippets in another EA, I would work through this list. The old tables don't test any of it.
- Check the server result after every Buy, Sell, PositionModify, and PositionClose, and don't stop at the bool [3].
- Send SL and TP with the order when the strategy and the broker allow it. If the entry takes two steps, treat the time before the stops are confirmed as its own state.
- Find the new position through the deal and its position identifier. Don't fall back to "any position on this symbol" unless the symbol, the magic number, and the open time show that it is the EA's own.
- Stop new entries while a request or a position's protection is unresolved.
- Build prices from fresh quotes, round them to the tick size, and handle a server rejection even when every local check passed.
- Limit retries, and make sure running out of them never drops the position from tracking, including after a terminal restart.
All six points follow one principle. After a request, verify the actual state of the intended position. Neither the bool nor the retcode alone confirms that the stops are there. If the state is not confirmed, the position stays in recovery and new entries are blocked. Appendix C shows a small helper, ConfirmProtection(), that sends a modify and reads the position back. It was written after the reruns. None of the measured builds use it, and it has run only in the Strategy Tester.
Limitations
- This is not an exact reproduction of the validator. Its tick model, delay, and freeze level are unknown, and my runs used a hedging account where the report names netting.
- The historical builds differ in several changes each. Only the ablation measures single changes, and only under a fixed delay of 5,000 ms.
- Random delay doesn't produce a repeatable sequence. The counts of the random-delay runs move between runs and are not meant to be repeated.
- All runs are on H1 with generated ticks. They show which kinds of error the code can produce, not how often they occur on M1 or whether the strategy is profitable there.
- The audit applies only to the ablation build, and it counts a position with at least one of SL or TP as protected.
- Recovery remains incomplete in both tested builds. If the modify and the close both fail, the code stops tracking an open position. The ConfirmProtection() helper was tested only in the Strategy Tester.
Conclusion
Validation passed, but it did not confirm reliable execution. My local tests with execution delay found rejected closes inside the freeze level, stops computed from an old price, and protection sent to the wrong position. Removing the timed exit stopped the rejected closes. Under a fixed delay, the burst-based distance removed most rejected modifications. The session buffer and the deal-based lookup were never tested by the case they guard against.
None of these changes solved the main defect. With a two-step entry, a failed modify followed by a failed close leaves an open position that the EA no longer tracks. For an EA like this, a more useful test than validation is whether every remaining position is still accounted for after a rejection, a closed market, or a restart. The next part starts there, with the recovery state the historical builds don't have.
References
- MetaQuotes, The checks a trading robot must pass before publication in the Market, MQL5 Articles.
- MQL5 Reference, SymbolInfoInteger, Symbol properties (SYMBOL_TRADE_STOPS_LEVEL, SYMBOL_TRADE_FREEZE_LEVEL, SYMBOL_TRADE_TICK_SIZE), and SymbolInfoSessionTrade.
- MQL5 Reference, Standard Library, CTrade: Buy, Sell, PositionModify, PositionClose, ResultRetcode, and ResultDeal.
- MQL5 Reference, Trade Server Return Codes: 10016, 10018, 10025, 10029, and 10036.
- MQL5 Reference, HistoryDealGetInteger and Deal properties (DEAL_POSITION_ID).
- MQL5 Reference, Position properties (POSITION_TICKET and POSITION_IDENTIFIER).
- MQL5 Reference, Custom symbols: CustomSymbolCreate, CustomSymbolSetInteger, and CustomRatesUpdate.
- MetaTrader 5 Help, Testing trading strategies, execution delay options.
Appendix A: tester settings
The Strategy Tester settings of all runs:
| Setting | Value |
|---|---|
| Symbols and timeframe | EURUSD and EURUSD_FZ, H1 |
| Dates, EURUSD | 2026.01.01 to 2026.09.20 |
| Dates, EURUSD_FZ | 2026.06.16 to 2026.09.20 |
| Tick model | Every tick, generated from the M1 history |
| Delays | Random delay in the debugging runs, a fixed delay of 5,000 ms in the ablation and sensitivity runs |
| Deposit | 10,000 USD |
| Leverage | 1:100 |
| Optimization | Disabled |
| Account | MetaQuotes-Demo, hedging |
| Spread | No fixed value. The tester takes the spread stored in the M1 history |
| Commission and fees | None charged, 0.00 USD in every ablation and sensitivity run |
| Swap | Charged by the symbol specification |
| Terminal | Build 6204 for the debugging runs, build 6252 for the ablation and sensitivity runs |
The exported settings of one fixed-delay run, as the tester reads them from its ini file:
[Tester] Expert=ExecutionDelay\ZetaBurst_Ablation.ex5 Symbol=EURUSD Period=H1 Model=0 Optimization=0 FromDate=2026.01.01 ToDate=2026.09.20 ForwardMode=0 Deposit=10000 Currency=USD Leverage=1:100 ExecutionMode=5000 [TesterInputs] InpBurstStopMult=0.0 InpSpreadInFloor=false InpSessionCloseBufferSec=0 InpDealLookup=false InpCooldownOnSend=false InpFloorMult=1.5 InpAuditGraceSec=60
ExecutionMode=-1 selects random delay, and a positive value is a fixed delay in ms. The tester keeps the last value of any input the ini file leaves out. In my first ablation batch, a switch from one run stayed on in the next, and two different rows came out identical. So every input of the ablation build is written out in each run.
The ablation build measured the spread on every tick. On EURUSD it ranged from 1 to 250 points, with a mean of 2.94 points. On EURUSD_FZ it ranged from 1 to 125 points, with a mean of 3.61 points. The EA skips entries when the spread is above 20 points, so the widest values only block trades.
The EURUSD_FZ runs cover three months, not eight. CopyRates returns only as many bars as the terminal's "Max bars in chart" setting allows. With my setting of 100,000, M1 history reaches back to 16 June, so that is all the history the custom symbol got. Raise the setting before running MakeFreezeSymbol.mq5 to copy a longer period.
Appendix B: full result tables
Random-delay runs on EURUSD. A single number in the columns of the published version and version 1.10 means every run gave that number.
| Error | First version | Guarded version | Published version | Version 1.10 |
|---|---|---|---|---|
| Close rejected, position already closed (10036) | 9 | 31 | 0 | 0 |
| Order rejected, invalid stops (10016) | 82 | 13 | 0 | 0 |
| Modify rejected, invalid stops (10016) | n/a | n/a | 39, 40, 27, 29, 35 | 10, 17, 14, 15, 25 |
| Trades | 1,064 | 16,489 | 7,777, 7,842, 8,001, 7,949, 7,931 | 7,680, 8,028, 7,812, 7,393, 7,538 |
| Net profit, USD | +4,897.36 | -9,204.56 | -8,141.93 to -8,404.91 | -7,986.60 to -8,825.94 |
Modify rejected, invalid stops (10016), EURUSD H1, five runs per build
The runs of one build differ only by the tester's random delay

Fig. 2. Rejected stop modifications (10016) in each of the five EURUSD runs, published version 1.00 against version 1.10
One run each on EURUSD_FZ, 16 June to 19 September 2026:
| Error | First version | Guarded version | Published version | Version 1.10 |
|---|---|---|---|---|
| Close rejected inside the freeze level (10029) | 749 | 1,958 | 0 | 0 |
| Close rejected, position already closed (10036) | 20 | 19 | 0 | 0 |
| Order rejected, invalid stops (10016) | 30 | 16 | 0 | 0 |
| Modify rejected, invalid stops (10016) | n/a | n/a | 998 | 8 |
| Market closed (10018) | 0 | 0 | 2 (one modify, one close) | 0 |
| Trades | 425 | 5,027 | 2,860 | 2,007 |
| Net profit, USD | +1,173.87 | -6,855.72 | -4,986.77 | -6,065.85 |
Rejection events per 1,000 trades of the same run. One trade can produce several errors. This is why the frozen-close figure exceeds 1,000.
| Rejections per 1,000 trades | First | Guarded | Published | 1.10 |
|---|---|---|---|---|
| EURUSD: invalid entry stops | 77.1 | 0.8 | 0.0 | 0.0 |
| EURUSD: invalid modify, five runs pooled | n/a | n/a | 4.3 | 2.1 |
| EURUSD: invalid modify, lowest and highest run | n/a | n/a | 3.4 and 5.1 | 1.3 and 3.3 |
| EURUSD_FZ: frozen close | 1,762.4 | 389.5 | 0.0 | 0.0 |
| EURUSD_FZ: invalid modify | n/a | n/a | 349.0 | 4.0 |
When I ran the attached files once more after tidying their comments, the published version gave 46 rejected modifies on EURUSD, above the range of the five recorded runs, and version 1.10 gave 14. The same kinds of error appeared, and no new kind.
Sensitivity runs with the ablation build, fixed delay of 5,000 ms. The freeze-level rows use EURUSD_FZ10 and EURUSD_FZ30, made with MakeFreezeSymbol.mq5 from the same history:
| Value tested | EURUSD: modify rejected (10016) | EURUSD: trades | EURUSD_FZ: modify rejected (10016) | EURUSD_FZ: trades |
|---|---|---|---|---|
| Freeze level 10 points, published version | n/a | n/a | 14 | 2,593 |
| Freeze level 20 points, published version | n/a | n/a | 9 | 2,220 |
| Freeze level 30 points, published version | n/a | n/a | 5 | 1,735 |
| Freeze level 10 points, all changes | n/a | n/a | 1 | 2,329 |
| Freeze level 20 points, all changes | n/a | n/a | 0 | 1,866 |
| Freeze level 30 points, all changes | n/a | n/a | 0 | 1,549 |
| Floor multiplier 1.0 | 4 | 7,812 | 1 | 2,443 |
| Floor multiplier 1.5, chosen | 4 | 7,812 | 0 | 1,866 |
| Floor multiplier 2.0 | 4 | 7,812 | 0 | 1,726 |
| Burst multiplier 1.0 | 16 | 8,182 | 0 | 1,991 |
| Burst multiplier 2.0, chosen | 4 | 7,812 | 0 | 1,866 |
| Burst multiplier 3.0 | 1 | 7,173 | 0 | 1,740 |
| Session buffer 1 minute | 4 | 7,817 | 0 | 1,866 |
| Session buffer 5 minutes, chosen | 4 | 7,812 | 0 | 1,866 |
| Session buffer 10 minutes | 4 | 7,769 | 0 | 1,872 |
A higher freeze level raises the distance floor. Fewer setups then pass the entry checks, and the trades fall. I kept the floor multiplier of 1.5 as the smallest tested value with no rejection on EURUSD_FZ, and the burst multiplier of 2.0 as a middle value, not an optimum. The session buffer gave the same counts at all three values, since no run reached the case it guards against. Five minutes is much longer than the longest window from the fill to the attached stops in the audit. That window was 25 seconds.
Appendix C: helper code
The distance floor used by the published version and version 1.10:
//+------------------------------------------------------------------+ //| GetMinStopDistance | //| Minimum distance (in price) between the open price and a stop. | //| Stops level and freeze level are measured from the price the | //| position closes at (Bid for a buy, Ask for a sell), so the spread| //| is added on top. When a broker reports a stops level of 0 the | //| spread is the only floor left. | //+------------------------------------------------------------------+ double GetMinStopDistance(const string symbol) { double point = SymbolInfoDouble(symbol, SYMBOL_POINT); long stopsLevel = SymbolInfoInteger(symbol, SYMBOL_TRADE_STOPS_LEVEL); long freezeLevel = SymbolInfoInteger(symbol, SYMBOL_TRADE_FREEZE_LEVEL); long spread = SymbolInfoInteger(symbol, SYMBOL_SPREAD); long minPoints = MathMax(stopsLevel, freezeLevel) + spread; return minPoints * point; }
The lookup by identifier in version 1.10. PositionGetTicket(i) returns the ticket and also selects that position, so PositionGetInteger() on the next line reads the right one:
//+------------------------------------------------------------------+ //| SelectPositionById | //| Select an open position by its POSITION_IDENTIFIER and hand back | //| its ticket. There is no PositionSelectByIdentifier() in MQL5, so | //| the open list has to be walked. Returns false if it is gone. | //+------------------------------------------------------------------+ bool SelectPositionById(const long posId, ulong &ticket) { ticket = 0; if(posId == 0) return false; for(int i = PositionsTotal() - 1; i >= 0; i--) { ulong t = PositionGetTicket(i); if(t == 0) continue; if(PositionGetInteger(POSITION_IDENTIFIER) == posId) { ticket = t; return true; } } return false; }
The ConfirmProtection() helper sends a modify and then reads the position back. I wrote it after the reruns, so none of the measured builds use it:
//+------------------------------------------------------------------+ //| ConfirmProtection | //| Send the modify, then read the position back. True only when the | //| position carries the requested levels. The return value and the | //| retcode are logged but do not decide: an unchanged modify came | //| back as 10016 in the tester, and a true return is not proof of | //| execution. False means "not confirmed", not "closed". | //+------------------------------------------------------------------+ bool ConfirmProtection(const ulong ticket, const double sl, const double tp) { //--- The caller has already checked that the ticket is ours and that sl //--- and tp sit on the right side of a fresh quote, rounded to tick size. bool sent = trade.PositionModify(ticket, sl, tp); uint rc = trade.ResultRetcode(); if(!PositionSelectByTicket(ticket)) return false; double tick = SymbolInfoDouble(PositionGetString(POSITION_SYMBOL), SYMBOL_TRADE_TICK_SIZE); if(tick <= 0.0) return false; bool levelsSet = MathAbs(PositionGetDouble(POSITION_SL) - sl) < tick * 0.5 && MathAbs(PositionGetDouble(POSITION_TP) - tp) < tick * 0.5; if(!levelsSet) PrintFormat("Protection not confirmed: ticket %I64u, returned %s, retcode %u %s", ticket, sent ? "true" : "false", rc, trade.ResultRetcodeDescription()); return levelsSet; }
It assumes a CTrade object named trade in synchronous mode. I compiled it into a test EA, ConfirmProtectionCheck.mq5, and ran it on the same EURUSD H1 settings with random delay. Every hour the EA closes its previous position, opens a buy with no stops, and asks the helper to attach them. On every second entry the stop loss goes 10 points above Bid, the wrong side for a buy. Every confirmed modify is then sent a second time with the same levels, as a retry after an unclear result would be.
| Case | Calls | Confirmed | Not confirmed |
|---|---|---|---|
| SL and TP 150 points from Bid | 2,229 | 2,229 | 0 |
| The same levels sent again | 2,229 | 2,229 | 0 |
| Stop loss 10 points above Bid | 2,228 | 0 | 2,228 |
The second row was unexpected. I expected the repeated modify to return TRADE_RETCODE_NO_CHANGES, 10025. The tester returned 10016, invalid stops, all 2,229 times, although the position already carried exactly those levels. A first draft of the helper that accepted only DONE or NO_CHANGES reported every one of those calls as a failure. I haven't checked what a live server returns in the same case. A false result means the stops weren't confirmed, not that the position is still open. Partial fills, a ticket change, and the delay before a live terminal updates the position weren't tested.
Attached files
| Filename | Install folder | Purpose |
|---|---|---|
| ZetaBurst.mq5 | MQL5/Experts/ExecutionDelay/ | Version 1.10, used in the result tables |
| ZetaBurst_Published.mq5 | MQL5/Experts/ExecutionDelay/ | Version 1.00, the build that passed submission 5 |
| PulseStrike_Scalper.mq5 | MQL5/Experts/ExecutionDelay/ | First version, before any fix |
| PulseStrike_Scalper_Guarded.mq5 | MQL5/Experts/ExecutionDelay/ | Forced exit with retry cap and stop-level check (submission 2) |
| ConfirmProtectionCheck.mq5 | MQL5/Experts/ExecutionDelay/ | Test EA for the ConfirmProtection() helper, Strategy Tester only |
| ZetaBurst_Ablation.mq5 | MQL5/Experts/ExecutionDelay/ | Ablation build: the published logic with each change of version 1.10 behind its own input, plus the audit of unprotected positions |
| MakeFreezeSymbol.mq5 | MQL5/Scripts/ExecutionDelay/ | Creates EURUSD_FZ, a custom EURUSD with a 20-point freeze level. Its inputs set the source, the name, and the freeze level |
| MQL5.zip | Terminal data folder | Archive with all seven files. Unpack it into the terminal data folder (File > Open Data Folder) and every file lands in its place |
The code of the four historical EAs is the code that produced the tables. Some of their comments were corrected to agree with the findings. All seven files compile with 0 errors and 0 warnings in build 6252.
Test any of this on a demo account before risking money on it.
Warning: All rights to these materials are reserved by MetaQuotes Ltd. Copying or reprinting of these materials in whole or in part is prohibited.
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.