A Trade Request Preflight Class in MQL5: Checking Volume, Stops, Margin and Sessions Before OrderSend
Contents
- Introduction
- What the server checks, and what is already published
- Architecture
- Section 1: PreflightTypes.mqh, result codes and symbol data
- Section 2: CPreflightRules, checks that need no broker
- Section 3: The delay buffer
- Section 4: CTradePreflight, the live checks
- Section 5: CPreflightLedger, counting refusals
- Section 6: The verification script
- Section 7: A stress test in the Strategy Tester
- Section 8: Adding the gate to an existing EA
- Section 9: Checking the numbers outside MQL5
- Section 10: Limitations
- Conclusion
- Attached files
Introduction
Any trade request an Expert Advisor sends can come back refused. In the Strategy Tester the refusal is a "failed" line in the journal with a retcode: 10014 for an invalid volume, 10016 for invalid stops, 10019 when there isn't enough money. On a live account the same lines go to the Journal tab, where they are easy to miss.
I learned how common these refusals are when a tick scalper of mine needed 5 submissions to pass the automatic CodeBase validation. The first 4 came back with 21 errors each. I still have one of those reports, and the errors I can still read in it are trade requests that my code had already checked and passed. I wrote up those failures and their fixes in an earlier article. This one is about the checks that could run before each request.
MetaQuotes has already published the standard list of checks in The checks a trading robot must pass before publication in the Market. It covers free margin, volume limits, the stops level, the freeze level and modifications that change nothing. I treat that article as the rulebook and won't repeat its explanations here. Artyom Trishkin's DoEasy series extends these checks in parts XXII, XXIII and XXIV. Its trading classes validate limits and parameters before a request is sent and can correct invalid values. However, that implementation is part of a large library. I wanted a component that can be dropped into a single EA. So my goal was one object that every supported request passes through before OrderSend(). I wanted it to work for any symbol and to be testable without a broker. It also had to fix small mistakes without raising the volume or widening a stop loss on its own, and keep count of what it refused.
This article builds that object: CTradePreflight. The module consists of 4 include files, a test script with 52 checks, and a stress EA that sends the same cycle of valid and invalid requests on every run. A Python script recomputes the stress results from the CSV files the EA writes. The stress EA also produced the result I didn't expect. With all rule checks enabled, 271 to 277 requests still failed at the server in three random-delay runs. All of them placed a level 1 or 2 points from the price. My hypothesis is that the price moved between the preflight check and the server's own validation of the request. I didn't log the price the server used, so Section 7 gives the indirect evidence for it. That result is the reason the gate has a delay buffer, and the last sections measure what the buffer costs on the same requests.
What the server checks, and what is already published
The broker sets several limits per symbol and per account. SYMBOL_VOLUME_MIN, SYMBOL_VOLUME_MAX and SYMBOL_VOLUME_STEP define which volumes exist at all. SYMBOL_VOLUME_LIMIT caps the total volume of positions and pending orders in one direction. ACCOUNT_LIMIT_ORDERS caps the number of pending orders. SYMBOL_TRADE_STOPS_LEVEL sets how close a stop loss, take profit or pending price may be to the market, and SYMBOL_TRADE_FREEZE_LEVEL sets the zone in which an existing position can no longer be modified or closed (see Symbol Properties). The MetaQuotes article gives a function for each of these limits.
When I read those functions closely, two of them refuse requests that the server would accept. CheckStopLoss_Takeprofit() tests the take profit of a buy with TP-Bid>stops_level*_Point. With no take profit, TP is 0, the expression is false and the whole request is refused. A sell with no stop loss fails the same way. The second case is SYMBOL_VOLUME_LIMIT. A short snippet in the same article treats a value of zero as no limit. NewOrderAllowedVolume(), however, subtracts the open volume from that zero and returns 0. On such a symbol that would block every order. The functions are short examples written for the chart symbol (Symbol() and _Symbol are used throughout), and as examples they do their job. Finding these two cases is what made me write unit tests for my own version before trusting it.
The MetaQuotes article does not use OrderCheck() either. That function asks the terminal to validate a complete MqlTradeRequest, margin included, and fills a result structure without sending anything. It is the last step in the gate.
I found three more recent articles only after most of this code was written. Omega J Msigwa's "Essential Validators" has functions for lot size, stop levels, the freeze level, free margin, unchanged modifications, the order limit and trading sessions. Ushana Kevin Iorkumbul's execution gateway puts lot rounding and a stop-level validator behind one class, and his order builder already calls OrderCheck() before it sends. So the individual checks in this article are not new. If you already use one of those, most of Sections 1 to 4 will look familiar.
None of these sources includes a test with execution delay enabled. The same is true of the MetaQuotes article and the DoEasy parts. Sections 3 and 7 are about that: how many requests still fail after all rule checks, and what it costs to stop them. One detail in the gateway article is worth checking if you use it. Its validator measures a buy's stop loss and take profit from Ask, the entry price, while the MetaQuotes article measures them from Bid, the price the position closes at. Going by the code, a buy stop placed exactly at the minimum distance from Ask sits one spread closer to Bid than the check assumes. On a symbol with a non-zero stops level it can still come back as 10016. I haven't run the gateway code to confirm this. It also moves a stop loss that is too close further away by default, where my gate refuses it unless the EA allows the wider stop.
Architecture
The gate is a straight pipeline. The EA calls one of four methods, depending on what it wants to do, and gets back a result code. PF_OK means send the request. Anything else means skip it, and LastDetail() explains why in plain text.

Fig. 1. CTradePreflight: every request runs through the same pipeline, and every result is counted by the ledger
The four methods cover market orders, the 4 basic pending order types, stop loss and take profit changes on an open position and full closes. Stop-limit orders, changes to pending orders and partial closes are outside the gate (see Section 10). Three of the four files can be tested without a broker. Only the last one needs a live symbol.
| File | Contents | Needs a live symbol |
|---|---|---|
| PreflightTypes.mqh | Result codes, the SSymbolSpec struct, PreflightCodeText() | No |
| PreflightRules.mqh | CPreflightRules: volume, tick grid, stops, pending price, freeze level, sessions | No |
| PreflightLedger.mqh | CPreflightLedger: counters per result code | No |
| TradePreflight.mqh | CTradePreflight: permissions, session, limits, margin, OrderCheck() | Yes |
Section 1: PreflightTypes.mqh, result codes and symbol data
Each check ends in one of 16 codes. I kept PF_NO_CHANGE apart from the refusals. A modification that changes nothing shouldn't be sent. It usually comes from a trailing stop that had no new level to set, though, so the ledger counts it on its own line. It stays out of the blocked total.
//+------------------------------------------------------------------+ //| Result codes returned by every preflight check | //+------------------------------------------------------------------+ enum ENUM_PREFLIGHT_CODE { PF_OK=0, // request can be sent PF_NO_CHANGE, // modify request would change nothing PF_TRADE_DISABLED, // terminal, account, EA or symbol forbids trading PF_DIRECTION_BLOCKED, // symbol allows only the other side or closing PF_MARKET_CLOSED, // no trade session at the current server time PF_VOLUME_INVALID, // volume below minimum, above maximum or off step PF_VOLUME_LIMIT, // SYMBOL_VOLUME_LIMIT would be exceeded PF_ORDER_LIMIT, // ACCOUNT_LIMIT_ORDERS already reached PF_NO_MONEY, // free margin does not cover the request PF_STOPS_SIDE, // SL, TP or pending price on the wrong side PF_STOPS_TOO_CLOSE, // SL, TP or pending price inside the stops level PF_PRICE_OFF_TICK, // SL, TP or pending price not on the tick grid PF_FROZEN, // position levels inside the freeze level PF_NOT_FOUND, // ticket could not be selected PF_BAD_REQUEST, // unsupported order type or no symbol data PF_SERVER_CHECK, // OrderCheck() refused with an ambiguous retcode PF_CODE_COUNT // number of codes, keep it last };
The rules never call SymbolInfoDouble() themselves. They read everything from an SSymbolSpec that CTradePreflight fills from the terminal, or that a test script fills by hand. This is why the test script can use a stops level of 30 on a broker whose real stops level is 0.
//+------------------------------------------------------------------+ //| Symbol data the rules need, read once per check | //+------------------------------------------------------------------+ struct SSymbolSpec { string name; // symbol name double point; // SYMBOL_POINT double tick_size; // SYMBOL_TRADE_TICK_SIZE int digits; // SYMBOL_DIGITS double bid; // Bid at the moment of the check double ask; // Ask at the moment of the check double vol_min; // SYMBOL_VOLUME_MIN double vol_max; // SYMBOL_VOLUME_MAX double vol_step; // SYMBOL_VOLUME_STEP int stops_level; // SYMBOL_TRADE_STOPS_LEVEL, points int freeze_level; // SYMBOL_TRADE_FREEZE_LEVEL, points int delay_points; // room for the price to move before the server check };
The last field, delay_points, is explained in Section 3.
Section 2: CPreflightRules, checks that need no broker
CPreflightRules holds static methods only. They take a spec, the request values and two or three flags, and they return a code. When a repair is allowed and needed, they change the value passed by reference and set changed to true.
Volume comes first. The repair mode rounds down to the step and clamps to the maximum, but it never raises a volume below the minimum. Raising it would mean the EA risks more than it asked for, so that case is always refused. Rounding down still changes the position the EA gets, so an EA that needs the exact size can switch this repair off (Section 10).
//+------------------------------------------------------------------+ //| CheckVolume: min, max and step. Repair only ever lowers volume | //+------------------------------------------------------------------+ ENUM_PREFLIGHT_CODE CPreflightRules::CheckVolume(const SSymbolSpec &s,double &volume,const bool repair, bool &changed,string &detail) { if(s.vol_step<=0.0 || s.vol_min<=0.0) { detail="symbol has no volume step or minimum"; return(PF_BAD_REQUEST); } double v=volume; //--- above the maximum: clamp down when allowed if(v>s.vol_max) { if(!repair) { detail=StringFormat("volume %.2f is above SYMBOL_VOLUME_MAX %.2f",volume,s.vol_max); return(PF_VOLUME_INVALID); } v=s.vol_max; } //--- round down to the step, never up double steps=MathFloor(v/s.vol_step+1e-7); double on_step=NormalizeDouble(steps*s.vol_step,VolumeDigits(s.vol_step)); if(MathAbs(on_step-v)>1e-8) { if(!repair) { detail=StringFormat("volume %.3f is not a multiple of SYMBOL_VOLUME_STEP %.3f",volume,s.vol_step); return(PF_VOLUME_INVALID); } v=on_step; } //--- below the minimum: raising it would raise the risk, so refuse if(v<s.vol_min-1e-8) { detail=StringFormat("volume %.3f is below SYMBOL_VOLUME_MIN %.2f",v,s.vol_min); return(PF_VOLUME_INVALID); } if(MathAbs(v-volume)>1e-8) { detail=StringFormat("volume %.3f changed to %.2f",volume,v); changed=true; volume=v; } return(PF_OK); }
The 1e-7 inside MathFloor() covers a floating point case. With a step of 0.1, the volume 0.3 divides to 2.9999999999999996 in floating point, and a plain MathFloor() would call a valid volume invalid. One of the unit tests covers exactly this case.
The stops check works one level at a time. A level of 0 means "not set" and passes, which is the fix for the TP=0 case above. A level on the wrong side of the price can't be repaired, because moving a take profit from below the price to above it would reverse what the caller wanted. Next comes the tick grid. The server takes only prices that are a whole number of tick sizes. On EURUSD the tick size equals the point, so this never fires there. On an index quoted in 0.01 with a 0.25 tick, a stop loss at 4490.13 can be far enough from the price and still be invalid. Without repair the gate refuses it with PF_PRICE_OFF_TICK. With repair it rounds the level away from the price, so the distance can only grow. Last, a level that is only too close can be moved away if the caller allows it.
//+------------------------------------------------------------------+ //| CheckLevel: one SL or TP against a reference price | //+------------------------------------------------------------------+ ENUM_PREFLIGHT_CODE CPreflightRules::CheckLevel(const SSymbolSpec &s,const string name,const bool below, const double ref,const int need,double &level, const bool repair,bool &changed,string &detail) { //--- 0 means the level is not set, there is nothing to check if(level==0.0) return(PF_OK); //--- distance in points, positive when the level sits on the correct side int dist=below ? Points(s,ref,level) : Points(s,level,ref); if(dist<0) { detail=StringFormat("%s %s is on the wrong side of %s",name, DoubleToString(level,s.digits),DoubleToString(ref,s.digits)); return(PF_STOPS_SIDE); } //--- the server takes only prices on the tick grid, however far they are if(!OnTickGrid(s,level)) { if(!repair) { detail=StringFormat("%s %s is not a multiple of the tick size %s",name, DoubleToString(level,s.digits),DoubleToString(s.tick_size,s.digits)); return(PF_PRICE_OFF_TICK); } //--- round away from the reference price, so the distance can only grow level=RoundToTick(s,level,below ? -1 : 1); changed=true; if(detail!="") detail+=", "; detail+=StringFormat("%s rounded to the tick grid at %s",name,DoubleToString(level,s.digits)); dist=below ? Points(s,ref,level) : Points(s,level,ref); } if(dist>need) return(PF_OK); //--- too close: move it away only when the caller allowed it if(!repair) { detail=StringFormat("%s %s is %d points from %s, needs more than %d",name, DoubleToString(level,s.digits),dist,DoubleToString(ref,s.digits),need); return(PF_STOPS_TOO_CLOSE); } double moved=below ? ref-(need+1)*s.point : ref+(need+1)*s.point; level=RoundToTick(s,moved,below ? -1 : 1); changed=true; if(detail!="") detail+=", "; detail+=StringFormat("%s moved to %s",name,DoubleToString(level,s.digits)); return(PF_OK); }
I used a strict comparison, dist>need, because that is what the MetaQuotes code does. Its text says "at least", which would accept a level at exactly the stops distance. The strict version refuses one extra point, and I prefer that to finding out which reading a particular server uses. It also means the ledger can count a request as blocked that the server would have taken. In the stress test of Section 7 no request sits exactly on that boundary, so the counts there are not affected.
CheckStops() picks the reference price and the distance, then runs CheckLevel() for the stop loss and the take profit. A market buy is measured from Bid and a market sell from Ask, because that is the price each position will close at. A pending order is measured from its own open price.
//+------------------------------------------------------------------+ //| CheckStops: SL and TP of a market or pending order | //+------------------------------------------------------------------+ ENUM_PREFLIGHT_CODE CPreflightRules::CheckStops(const SSymbolSpec &s,const ENUM_ORDER_TYPE type, const double open_price,double &sl,double &tp, const bool repair_sl,const bool repair_tp, bool &changed,string &detail) { bool is_market=(type==ORDER_TYPE_BUY || type==ORDER_TYPE_SELL); bool is_pending=(type==ORDER_TYPE_BUY_LIMIT || type==ORDER_TYPE_BUY_STOP || type==ORDER_TYPE_SELL_LIMIT || type==ORDER_TYPE_SELL_STOP); if(!is_market && !is_pending) { detail="order type "+EnumToString(type)+" is not supported"; return(PF_BAD_REQUEST); } bool is_buy=(type==ORDER_TYPE_BUY || type==ORDER_TYPE_BUY_LIMIT || type==ORDER_TYPE_BUY_STOP); //--- a market position closes at Bid (buy) or Ask (sell), a pending order at its own price double ref=is_market ? (is_buy ? s.bid : s.ask) : open_price; //--- the buffer covers the market moving before the server checks the request. //--- a pending order's SL and TP are measured from its own fixed price, so no buffer int need=s.stops_level+(is_market ? s.delay_points : 0); ENUM_PREFLIGHT_CODE code=CheckLevel(s,"SL",is_buy,ref,need,sl,repair_sl,changed,detail); if(code!=PF_OK) return(code); return(CheckLevel(s,"TP",!is_buy,ref,need,tp,repair_tp,changed,detail)); }
The two repair flags are separate on purpose. Moving a take profit further away changes how often a trade wins, not how much it can lose. Moving a stop loss further away increases the loss per trade, so the gate only does it when the EA sets repair_sl explicitly. By default it is off.
The freeze level check looks at the levels the position already has, before any change is applied. If the existing stop loss or take profit is already inside the freeze zone, the server won't accept a modification or a close for that position.
//+------------------------------------------------------------------+ //| IsFrozen: true when an existing SL or TP is inside freeze level | //+------------------------------------------------------------------+ bool CPreflightRules::IsFrozen(const SSymbolSpec &s,const bool is_buy,const double sl,const double tp) { if(s.freeze_level<=0) return(false); double ref=is_buy ? s.bid : s.ask; //--- TP above the price for a buy, below it for a sell if(tp>0.0) { int dist=is_buy ? Points(s,tp,ref) : Points(s,ref,tp); if(dist<=s.freeze_level) return(true); } //--- SL below the price for a buy, above it for a sell if(sl>0.0) { int dist=is_buy ? Points(s,ref,sl) : Points(s,sl,ref); if(dist<=s.freeze_level) return(true); } return(false); }
Section 3: The delay buffer
Every check in Section 2 compares a level with a price read at a single moment. The server applies its own rules at a later moment, when it validates the request. In between, the price moves by an amount the EA doesn't control: network time, server queue, or the delay setting of the Strategy Tester.
On MetaQuotes-Demo, EURUSD has a stops level of 0 in my tests. A buy with a stop loss 2 points under Bid passes every rule check, since 2 is more than 0. If Bid falls 3 points before the server gets to the request, the same stop loss is now above Bid and the request fails with 10016. Nothing in the MetaQuotes checks or in my Section 2 code can see that coming, because the problem happens after the check.
The delay_points field adds room for that movement. For market orders CheckStops() adds it to the stops level, as the snippet above shows. For pending orders CheckPendingPrice() adds it to the required distance between the open price and the market, because the market can move toward the order before the server places it.
//+------------------------------------------------------------------+ //| CheckPendingPrice: open price of a pending order vs the market | //+------------------------------------------------------------------+ ENUM_PREFLIGHT_CODE CPreflightRules::CheckPendingPrice(const SSymbolSpec &s,const ENUM_ORDER_TYPE type, const double price,string &detail) { int dist=0; switch(type) { case ORDER_TYPE_BUY_LIMIT: dist=Points(s,s.ask,price); break; case ORDER_TYPE_BUY_STOP: dist=Points(s,price,s.ask); break; case ORDER_TYPE_SELL_LIMIT: dist=Points(s,price,s.bid); break; case ORDER_TYPE_SELL_STOP: dist=Points(s,s.bid,price); break; default: detail="order type "+EnumToString(type)+" is not a pending order"; return(PF_BAD_REQUEST); } if(!OnTickGrid(s,price)) { detail=StringFormat("%s price %s is not a multiple of the tick size %s",EnumToString(type), DoubleToString(price,s.digits),DoubleToString(s.tick_size,s.digits)); return(PF_PRICE_OFF_TICK); } //--- the delay buffer applies here too: the market can move toward the price int need=s.stops_level+s.delay_points; if(dist<0) { detail=StringFormat("%s price %s is on the wrong side of the market", EnumToString(type),DoubleToString(price,s.digits)); return(PF_STOPS_SIDE); } if(dist<=need) { detail=StringFormat("%s price %s is %d points from the market, needs more than %d", EnumToString(type),DoubleToString(price,s.digits),dist,need); return(PF_STOPS_TOO_CLOSE); } return(PF_OK); }
It is not added to the stop loss and take profit of a pending order, because those are measured from the order's own fixed price. The test script checks both halves of that rule.
//+------------------------------------------------------------------+ //| TestDelayBuffer: extra distance for market orders only | //+------------------------------------------------------------------+ void TestDelayBuffer(void) { SSymbolSpec s=MakeSpec(); s.delay_points=20; string d=""; bool ch=false; double sl,tp=0.0; //--- market buy now needs more than 30 + 20 points sl=1.09960; Expect(CPreflightRules::CheckStops(s,ORDER_TYPE_BUY,0.0,sl,tp,false,false,ch,d)==PF_STOPS_TOO_CLOSE, "buy SL 40 points away refused with a 20 point buffer"); sl=1.09949; Expect(CPreflightRules::CheckStops(s,ORDER_TYPE_BUY,0.0,sl,tp,false,false,ch,d)==PF_OK, "buy SL 51 points away passes with a 20 point buffer"); //--- SL of a pending order is measured from its own price, the buffer does not apply sl=1.09869; Expect(CPreflightRules::CheckStops(s,ORDER_TYPE_BUY_LIMIT,1.09900,sl,tp,false,false,ch,d)==PF_OK, "buy limit SL 31 points under its price passes, buffer on"); }
The buffer is not the deviation. Deviation is the slippage the EA accepts when a market order fills, and CTrade sends it to the server with the request. A larger deviation lets a trade fill at a worse price. The buffer never reaches the server. It only makes the gate refuse more requests before sending, so it can't make a fill worse. My first stress EA used one input for both, which mixed the two effects. Now they are separate inputs, and the deviation stays at 10 points in every run of Section 7. I kept 10 points because it is the default deviation of CTrade, and it equals 1 pip on 5-digit EURUSD. I did not test other values. Deviation applies only to market orders, so the modify step and the buy limit step don't depend on it. The market buy step failed with 10016, invalid stops, in all three buffer-0 runs. That code concerns the stop level rather than the fill price. However, another deviation could change which market orders fill and at what price, so the counts in Section 7 hold for 10 points only.
What value to use is an open question that Section 7 answers only for one symbol and one tester setting. Too small a buffer lets delayed requests fail, and too large a one blocks requests the server would have taken.
Section 4: CTradePreflight, the live checks
CTradePreflight wraps the rules with the checks that need a real terminal. LoadSpec() fills SSymbolSpec from SymbolInfoTick() and the symbol properties. If there is no tick, the request is refused with PF_BAD_REQUEST, because every later check depends on a price. So the gate is not a static check. It judges each request against the terminal state at the moment of the call.
CheckPermissions() reads the terminal's AutoTrading switch and the program's own "Allow Algo Trading" setting, then ACCOUNT_TRADE_ALLOWED and ACCOUNT_TRADE_EXPERT, then SYMBOL_TRADE_MODE. The first two belong to the live terminal, so the gate reads them only outside the tester. A symbol in close-only, long-only or short-only mode returns PF_DIRECTION_BLOCKED for the side it doesn't allow.
The session check walks the symbol's trade sessions for the current weekday with SymbolInfoSessionTrade() and compares them with the trade server time. Then it walks yesterday's sessions too, because a session that starts before midnight may continue into today.
//+------------------------------------------------------------------+ //| IsSessionOpen: is the server time inside a trade session | //+------------------------------------------------------------------+ bool CTradePreflight::IsSessionOpen(const string symbol) { MqlDateTime now; TimeToStruct(TimeTradeServer(),now); uint seconds=(uint)(now.hour*3600+now.min*60+now.sec); datetime from=0,to=0; //--- today's sessions first, then yesterday's in case one runs past midnight for(int back=0; back<=1; back++) { ENUM_DAY_OF_WEEK day=(ENUM_DAY_OF_WEEK)((now.day_of_week+7-back)%7); //--- a day can hold several sessions, walk them until the terminal says there are no more for(uint i=0; SymbolInfoSessionTrade(symbol,day,i,from,to); i++) { if(CPreflightRules::SessionCovers(seconds,(uint)from,(uint)to,back==1)) return(true); } } m_detail=StringFormat("no trade session for %s at %s",symbol, TimeToString(TimeTradeServer(),TIME_DATE|TIME_SECONDS)); return(false); }
The documentation doesn't say under which day such a session is listed, or how its end is written. So SessionCovers() accepts both forms I could think of: an end before the start, or an end beyond 24:00. For yesterday's sessions it takes only the part after midnight.
//+------------------------------------------------------------------+ //| SessionCovers: is a time of day (seconds) inside one session. | //| from_yesterday checks only the part of yesterday's session that | //| runs past midnight | //+------------------------------------------------------------------+ bool CPreflightRules::SessionCovers(const uint seconds,const uint start,const uint end, const bool from_yesterday) { const uint day=86400; if(from_yesterday) { //--- written with an end beyond 24:00, or with the end before the start if(end>day) return(seconds<end-day); if(start>end) return(seconds<end); return(false); } if(end>day) return(seconds>=start); //--- across midnight: the evening part, and the morning part if the day lists it if(start>end) return(seconds>=start || seconds<end); return(seconds>=start && seconds<end); }
No EURUSD session on MetaQuotes-Demo crosses midnight, so this path is covered by 7 unit tests only.
The volume limit check is where the zero case from the MetaQuotes article is handled. It returns early when SYMBOL_VOLUME_LIMIT is 0. When there is a limit and the request exceeds the room left, repair mode cuts the volume to that room, but only if the result is still a valid volume under CheckVolume().
//+------------------------------------------------------------------+ //| CheckVolumeLimit: SYMBOL_VOLUME_LIMIT, where 0 means no limit | //+------------------------------------------------------------------+ ENUM_PREFLIGHT_CODE CTradePreflight::CheckVolumeLimit(const SSymbolSpec &s,const bool is_buy,double &volume) { double limit=SymbolInfoDouble(s.name,SYMBOL_VOLUME_LIMIT); if(limit<=0.0) return(PF_OK); double room=limit-DirectionVolume(s.name,is_buy); if(volume<=room+1e-8) return(PF_OK); if(!m_repair_volume) { m_detail=StringFormat("volume %.2f exceeds the %.2f left under SYMBOL_VOLUME_LIMIT",volume,room); return(PF_VOLUME_LIMIT); } //--- shrink to the room that is left, if that is still a tradable volume double reduced=room; bool changed=false; string note=""; if(room<=0.0 || CPreflightRules::CheckVolume(s,reduced,true,changed,note)!=PF_OK) { m_detail=StringFormat("only %.2f left under SYMBOL_VOLUME_LIMIT",MathMax(room,0.0)); return(PF_VOLUME_LIMIT); } m_detail+=StringFormat(" volume cut to %.2f by SYMBOL_VOLUME_LIMIT",reduced); volume=reduced; m_repaired=true; return(PF_OK); }
The margin check calls OrderCalcMargin() for the final volume and compares the result with ACCOUNT_MARGIN_FREE. It is a rough check, and it only runs on hedging accounts. OrderCalcMargin() prices the new order alone. On a netting or exchange account an order can reduce a position or be offset against others, so there the gate leaves money to OrderCheck(). SetMarginCheck(false) turns the own check off everywhere. It refuses rather than repairs. Shrinking a position because the account is short of money is a sizing decision, and the EA should make it.
//+------------------------------------------------------------------+ //| CheckMargin: margin for the request against free margin | //+------------------------------------------------------------------+ ENUM_PREFLIGHT_CODE CTradePreflight::CheckMargin(const SSymbolSpec &s,const bool is_buy,const double volume, const double price) { //--- netting and exchange margin can offset open positions, OrderCheck() decides there if(!m_check_margin || (ENUM_ACCOUNT_MARGIN_MODE)AccountInfoInteger(ACCOUNT_MARGIN_MODE)!=ACCOUNT_MARGIN_MODE_RETAIL_HEDGING) return(PF_OK); double margin=0.0; ENUM_ORDER_TYPE type=is_buy ? ORDER_TYPE_BUY : ORDER_TYPE_SELL; if(!OrderCalcMargin(type,s.name,volume,price,margin)) { m_detail=StringFormat("OrderCalcMargin failed, error %d",GetLastError()); return(PF_BAD_REQUEST); } double free_margin=AccountInfoDouble(ACCOUNT_MARGIN_FREE); if(margin>free_margin) { m_detail=StringFormat("%.2f lots need %.2f margin, free margin is %.2f",volume,margin,free_margin); return(PF_NO_MONEY); } return(PF_OK); }
Then OrderCheck() gets the final request, after every repair. A true return only means that the request passed a basic check of its structure. The documentation says to read the result structure for the verdict, and a successful check has retcode 0 there. My first version returned PF_OK as soon as OrderCheck() returned true, which is exactly the shortcut the documentation warns against. ServerCheck() now passes a request only when both hold. In the stress runs of Section 7, OrderCheck() returned retcode 0 for every request that reached it, so the fix changed no count there. It matters on a server where the terminal's check finds something my rules missed.
//+------------------------------------------------------------------+ //| ServerCheck: OrderCheck() dry run, judged by its retcode | //+------------------------------------------------------------------+ ENUM_PREFLIGHT_CODE CTradePreflight::ServerCheck(MqlTradeRequest &request) { if(!m_use_order_check) return(PF_OK); MqlTradeCheckResult result; ZeroMemory(result); bool checked=OrderCheck(request,result); m_last_retcode=result.retcode; //--- true only means the structure passed a basic check, the retcode is the verdict if(checked && result.retcode==0) return(PF_OK); m_detail=StringFormat("OrderCheck returned %s, retcode %u %s",checked ? "true" : "false", result.retcode,result.comment); //--- map only retcodes with one meaning, LastRetcode() keeps the raw value for the rest switch(result.retcode) { case TRADE_RETCODE_NO_MONEY: return(PF_NO_MONEY); case TRADE_RETCODE_INVALID_VOLUME: return(PF_VOLUME_INVALID); case TRADE_RETCODE_MARKET_CLOSED: return(PF_MARKET_CLOSED); case TRADE_RETCODE_TRADE_DISABLED: return(PF_TRADE_DISABLED); case TRADE_RETCODE_LIMIT_ORDERS: return(PF_ORDER_LIMIT); case TRADE_RETCODE_LIMIT_VOLUME: return(PF_VOLUME_LIMIT); case TRADE_RETCODE_FROZEN: return(PF_FROZEN); default: break; } return(PF_SERVER_CHECK); }
Only retcodes with one meaning are mapped back to gate codes, so the ledger shows "no money" whether my own margin check or the terminal found the problem. A 10016 from OrderCheck() could mean a wrong side, a level too close or a price off the tick grid. It goes to PF_SERVER_CHECK, and LastRetcode() keeps the raw value for the EA to read.
CheckMarket() puts the pieces in order. Permissions and the session come first because they refuse everything. Volume and the volume limit come next, since both can change the volume. The stops follow, and money comes after them, because the margin depends on the final volume. OrderCheck() comes last, on the final volume, stop loss, take profit and deviation. The price in that request is the Bid or Ask at the moment of the check.
//+------------------------------------------------------------------+ //| CheckMarket: a market buy or sell, may change volume, sl and tp | //+------------------------------------------------------------------+ ENUM_PREFLIGHT_CODE CTradePreflight::CheckMarket(const string symbol,const ENUM_ORDER_TYPE type, double &volume,double &sl,double &tp) { Begin(); if(type!=ORDER_TYPE_BUY && type!=ORDER_TYPE_SELL) { m_detail="CheckMarket takes ORDER_TYPE_BUY or ORDER_TYPE_SELL"; return(Finish("market",symbol,PF_BAD_REQUEST)); } SSymbolSpec s; if(!LoadSpec(symbol,s)) return(Finish("market",symbol,PF_BAD_REQUEST)); bool is_buy=(type==ORDER_TYPE_BUY); //--- permissions and session ENUM_PREFLIGHT_CODE code=CheckPermissions(symbol,is_buy ? 1 : -1); if(code!=PF_OK) return(Finish("market",symbol,code)); if(!IsSessionOpen(symbol)) return(Finish("market",symbol,PF_MARKET_CLOSED)); //--- volume bool changed=false; code=CPreflightRules::CheckVolume(s,volume,m_repair_volume,changed,m_detail); if(changed) m_repaired=true; if(code==PF_OK) code=CheckVolumeLimit(s,is_buy,volume); if(code!=PF_OK) return(Finish("market",symbol,code)); //--- stops changed=false; code=CPreflightRules::CheckStops(s,type,0.0,sl,tp,m_repair_sl,m_repair_tp,changed,m_detail); if(changed) m_repaired=true; if(code!=PF_OK) return(Finish("market",symbol,code)); //--- money double price=is_buy ? s.ask : s.bid; code=CheckMargin(s,is_buy,volume,price); if(code!=PF_OK) return(Finish("market",symbol,code)); //--- the server's own view of the final request MqlTradeRequest request; ZeroMemory(request); request.action =TRADE_ACTION_DEAL; request.symbol =symbol; request.volume =volume; request.type =type; request.price =price; request.sl =sl; request.tp =tp; request.deviation =m_deviation; request.type_filling=Filling(symbol); return(Finish("market",symbol,ServerCheck(request))); }
The deviation written into that request comes from SetDeviation(), not from the buffer. The EA should give it the same value it gives CTrade, so OrderCheck() sees the deviation that is sent. However, the price can differ. When an EA calls CTrade::Buy() or CTrade::Sell() with the price 0.0, as the stress EA does, CTrade reads the current price again at the moment of sending. So OrderCheck() checks an equivalent request at the time of the preflight, not the exact request the server receives. An EA that needs the identical request has to read the price once and send that same price, through CTrade or through its own MqlTradeRequest and OrderSend(). The class does not return the price it checked, and the Limitations section lists this gap.
CheckPending() follows the same order with 2 extra steps. It checks ACCOUNT_LIMIT_ORDERS first. It also checks the open price against the market before it measures the stop loss and take profit from that price. An open price off the tick grid is refused, not rounded, because moving the entry is the EA's decision. CheckClose() checks permissions, the session and the freeze level, then runs OrderCheck() on the closing deal.
CheckModify() has one step the others don't. Before anything else, it compares the new levels with the ones the position already has.
//--- a request that changes nothing is refused by the server, so skip it here if(CPreflightRules::SameLevel(s,sl,cur_sl) && CPreflightRules::SameLevel(s,tp,cur_tp)) { m_detail=StringFormat("position #%I64u already has these levels",ticket); return(Finish("modify",symbol,PF_NO_CHANGE)); }
I expected an unchanged modification to come back as 10025, TRADE_RETCODE_NO_CHANGES. In MetaTrader 5 build 6204 it came back as 10016, invalid stops, every time I saw it: 421, 420 and 421 times in the three stress-test runs with the gate off. I first noticed it while fixing the scalper from the introduction. Here I could count it. That matters for anyone who reads retcodes to decide what went wrong, because an unchanged trailing stop and a stop that is too close produce the same code. The gate removes the first case before it reaches the server.
Section 5: CPreflightLedger, counting refusals
Every public method ends in Finish(), which records the code in CPreflightLedger and, when verbose output is on, prints one line with the reason. The ledger is a counter per code plus a count of requests that passed only after a repair. At the end of a test, Report() prints it. This is the output of the stress test from Section 7 with the gate on and a 10-point buffer:
2026.09.18 23:59:59 ---- CTradePreflight ledger ---- 2026.09.18 23:59:59 checks 4440, passed 1774 (after repair 446), no change 442, blocked 2224 2026.09.18 23:59:59 no money 446 2026.09.18 23:59:59 stops wrong side 446 2026.09.18 23:59:59 stops too close 1332
The per-request lines are more useful while debugging. Three from the same run:
2026.01.02 01:00:00 Preflight market EURUSD: ok. volume 0.107 changed to 0.10 2026.01.02 02:00:00 Preflight market EURUSD: stops too close. SL 1.17505 is 2 points from 1.17507, needs more than 10 2026.01.02 04:00:00 Preflight market EURUSD: no money. 50.00 lots need 168412.00 margin, free margin is 9879.10
The margin in the last line was unexpected. At a price near 1.175, 50 lots at 1:100 should need about 58,750 USD, and OrderCalcMargin() returned about 2.9 times that. The tester log confirms the 1:100 leverage, so the symbol's own margin settings on this server probably add the rest. I didn't investigate which setting causes this. It is a good reason for the gate to ask the terminal for the margin instead of working it out from the leverage.
Section 6: The verification script
TestTradePreflight.mq5 runs as a script. It builds a spec for a 5-digit symbol with Bid 1.10000, Ask 1.10010, a stops level of 30 and a freeze level of 10, and calls the rules directly. Each check prints PASS or FAIL through a small helper.
//+------------------------------------------------------------------+ //| Expect: print one result line and count it | //+------------------------------------------------------------------+ void Expect(const bool condition,const string name) { if(condition) g_pass++; else g_fail++; Emit(StringFormat("%s %s",condition ? "PASS" : "FAIL",name)); }
The 52 checks start with the volume step, minimum and maximum, including the 0.3 and 0.1 floating point case. Then come stop loss and take profit tests for buys and sells at 30 and 31 points. After them come requests with no stop loss or take profit, wrong-side levels, both repair flags, the delay buffer, the 4 pending order types and the freeze level. The last groups cover rounding to a 0.25 tick for index-style symbols, levels and pending prices off that grid, and seven session cases, four of them around midnight.

Fig. 2. The lines TestTradePreflight.mq5 prints, saved as a PNG by its InpSaveImage option: 52 passed, 0 failed
The script prints to the Experts tab. With InpSaveImage set to true it also writes the same lines on a temporary white chart, saves that chart as TestTradePreflight.png in MQL5\Files with ChartScreenShot() and closes it again. Figure 2 was made that way. In my first attempt a few lines were cut short, because label text on the chart stopped after 63 characters, so I shortened the longest test names. During development I ran the script without opening a chart, by starting the terminal with a configuration file whose [StartUp] section names the script, a symbol and a period. The output then goes to the terminal's MQL5\Logs folder.
The tests use no broker data, so they give the same result on any account. That shows the rules do what I wrote them to do. It doesn't show that a given server applies the same rules, and for a non-zero stops or freeze level I have no server confirmation yet (Section 10).
Section 7: A stress test in the Strategy Tester
The unit tests show that the rules do what I intended. They can't show whether my intentions match the server. For that I wrote PreflightStressEA.mq5. It is not a strategy. On each new bar it sends the next request from a fixed cycle of 10, so every run sends the same mix.
| Step | Request | What it tests |
|---|---|---|
| 0 | Buy 0.10, SL 300 points, TP 600 points | A normal request |
| 1 | Sell 0.107 | Volume off the 0.01 step |
| 2 | Buy with SL 2 points under Bid | A scalper stop, legal only if the price holds still |
| 3 | Sell with TP computed above the price | A sign error in the EA |
| 4 | Buy 50 lots | Far more margin than a 10,000 USD account has |
| 5 | Modify the newest position with its own SL and TP | An unchanged trailing step |
| 6 | Modify the newest position's SL to 1 point from the price | A trailing stop pulled too tight |
| 7 | Close the oldest position | A normal close |
| 8 | Buy limit 1 point under Ask | A pending price too close to the market |
| 9 | Buy with SL 300 points and no TP | Legal, and refused by the MetaQuotes sample code |
All runs used the same tester settings:
| Parameter | Value |
|---|---|
| Symbol and timeframe | EURUSD, H1 |
| Period | 2026.01.01 to 2026.09.20 |
| Modeling | Every tick |
| Execution delay | Random delay, a mode with no setting for its range |
| Deposit | 10,000 USD |
| Leverage | 1:100 |
| Server and account | MetaQuotes-Demo, hedging |
| Deviation (CTrade and gate) | 10 points |
| Spread | Not fixed, taken from the MetaQuotes-Demo symbol data |
| Commission | 0.00 in every account state line of the log |
| Swaps | From the symbol specification, not controlled |
The configuration files set no spread, commission or swap. These came from MetaQuotes-Demo as it was when I ran the tests, so I did not control them. The random delay has no range to set, and the delays can't be reproduced exactly. For this reason each setting ran three times.
I ran three settings: gate off, gate on with no buffer, and gate on with a 10-point buffer. Every input was written into each run's configuration file, and I checked the input echo at the start of each log. The number of requests differs a little between runs because steps 5 to 7 only run when a position is open, and random delay changes how long positions last.
The EA counts every request by step and outcome itself and prints the totals when the test ends. It also writes one CSV line per request, and the Python script in Section 9 rebuilds every table below from those files.
| Run | Requests | Stopped by gate | Skipped, no change | Sent | Rejected by server |
|---|---|---|---|---|---|
| Gate off | 4,342 | 0 | 0 | 4,342 | 2,044 |
| Gate on, buffer 0 | 4,415 | 892 | 442 | 3,081 | 275 |
| Gate on, buffer 10 | 4,440 | 2,224 | 442 | 1,774 | 0 |
Requests stopped by the gate against requests rejected by the server
PreflightStressEA, EURUSD H1, 2026.01.01 to 2026.09.20, every tick, random delay, first run of each setting

Fig. 3. Requests stopped by the gate before sending, against requests the server rejected, first run of each setting
With the gate off, the 2,044 rejections split cleanly by step:
| Step | Sent | Rejected | Retcode |
|---|---|---|---|
| 1, off-step volume | 446 | 446 | 10014 invalid volume |
| 2, SL 2 points under Bid | 446 | 73 | 10016 invalid stops |
| 3, TP on the wrong side | 446 | 446 | 10016 invalid stops |
| 4, 50 lots | 446 | 446 | 10019 no money |
| 5, unchanged modification | 421 | 421 | 10016 invalid stops |
| 6, SL 1 point from the price | 413 | 103 | 10016 invalid stops |
| 8, buy limit 1 point under Ask | 445 | 109 | 10015 invalid price |
Steps 0, 7 and 9 had no rejections in any run.
The buffer-0 run is the one I built the article around. Here the gate applies only the rule checks against the price it reads, which is roughly what the MetaQuotes article asks for. It repaired all 446 off-step volumes, stopped the 446 wrong-side take profits and the 446 oversized buys, and skipped 442 unchanged modifications. Still, 275 requests failed at the server: 74 buys from step 2, 88 tight modifications from step 6 and 113 buy limits from step 8. The gate had passed every one of them.
I didn't log the price the server used, so the cause of those 275 is a hypothesis, not a measured result. Three things point to price movement during the delay. Every failure came from one of the three steps that place a level 1 or 2 points from the price. The same steps went through most of the time, 75% to 83% of sends in this run. No request with levels 300 points away failed at all. For the market buys and the modifications, a move of 2 or 3 points against the level is enough to produce 10016. The buy limits failed with 10015, invalid price, which the server returns when it validates the placement, before any fill. For those I can only say that the order was no longer a valid buy limit when the server checked it, most likely because Ask had fallen to its price. The same pattern held in all three buffer-0 runs. Steps 2 and 6 returned only 10016, and step 8 returned only 10015. A test could confirm or reject the hypothesis. It would log Bid and Ask before the gate, before the send and after the result. It would also repeat the runs with several delay modes. I have not run that test.
To measure the buffer's cost, the comparison must use the same requests. Separate runs cannot ensure this because random delay changes which requests fail. So in each buffer-0 run a second CTradePreflight object with a 10-point buffer, the shadow gate, checked every request after the real gate had approved it. It worked on copies of the values and never blocked anything. The EA recorded its verdict next to the server's result for that same request:
| Step | Sent | Shadow blocks, server rejected | Shadow blocks, server accepted | Shadow passes |
|---|---|---|---|---|
| 2, SL 2 points under Bid | 446 | 74 | 372 | 0 |
| 6, SL 1 point from the price | 440 | 88 | 352 | 0 |
| 8, buy limit 1 point under Ask | 445 | 113 | 332 | 0 |
| All steps | 3,081 | 275 | 1,056 | 1,750 |
The same requests: what a 10-point buffer would block, and what the server did
First buffer-0 run, EURUSD H1, random delay, shadow gate with a 10-point buffer checked on copies of each sent request

Fig. 4. The same requests of the first buffer-0 run, split by what a 10-point buffer would have done and what the server did
In run 1 the 10-point buffer would have stopped all 275 requests the server rejected, and 1,056 that it accepted. It passed no request that the server then rejected. Runs 2 and 3 gave 277 and 1,053, then 271 and 1,061, again with no miss. So for requests placed one or two points from the price, the buffer blocks 3.80 to 3.92 accepted requests for every rejection it prevents. That is a bad trade for requests like mine. Steps 0 and 9, with stops 300 points away, were never blocked by the buffer.
The shadow numbers are exact only inside one run. The separate buffer-10 runs follow a different path, since blocking steps 2, 6 and 8 leaves different positions open. Step 7, for example, closed 437 positions there against 413 in the buffer-0 run. For the same reason the gate off and gate on runs differ in more than the gate itself. I compare them only through the totals.
I ran each setting three times with the same inputs. The rejections in the buffer-0 runs were 275 (74, 88 and 113 from steps 2, 6 and 8), 277 (67, 95 and 115) and 271 (73, 95 and 103), always from those three steps. With the gate off, the server rejected 2,044, 2,041 and 2,025 requests, from the same steps as before. The unchanged modifications came back as 10016 in all three gate-off runs. The buffer-10 runs gave the same result all three times: 1,774 sent and 0 rejected. These are the server rejections from all nine runs:
| Setting | Run 1 | Run 2 | Run 3 |
|---|---|---|---|
| Gate off | 2,044 | 2,041 | 2,025 |
| Gate on, buffer 0 | 275 | 277 | 271 |
| Gate on, buffer 10 | 0 | 0 | 0 |
Section 8: Adding the gate to an existing EA
The gate is one object and one extra call per request. The stress EA configures it in OnInit() next to CTrade. CTrade and the gate get the same deviation, and the buffer has its own input.
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { ArrayInitialize(g_attempted,0); ArrayInitialize(g_blocked,0); ArrayInitialize(g_skipped,0); ArrayInitialize(g_sent,0); ArrayInitialize(g_rejected,0); ArrayInitialize(g_sh,0); g_trade.SetExpertMagicNumber(InpMagic); g_trade.SetDeviationInPoints((ulong)MathMax(InpDeviation,0)); g_trade.SetTypeFillingBySymbol(_Symbol); g_gate.SetDelayPoints(InpDelayPoints); g_gate.SetDeviation(InpDeviation); g_gate.SetRepair(InpRepairVolume,InpRepairTP,InpRepairSL); //--- the shadow sees the request after the real gate approved it, with another buffer g_use_shadow=(InpUseGate && InpShadowPoints>=0); g_shadow.SetDelayPoints(InpShadowPoints); g_shadow.SetDeviation(InpDeviation); g_shadow.SetRepair(InpRepairVolume,InpRepairTP,InpRepairSL); g_shadow.SetVerbose(false); if(InpRunTag!="") { g_csv=FileOpen("PreflightStress_"+InpRunTag+".csv",FILE_WRITE|FILE_CSV|FILE_ANSI|FILE_COMMON,','); if(g_csv==INVALID_HANDLE) Print("CSV could not be opened, error ",GetLastError()); else FileWrite(g_csv,"time","step","gate","shadow","sent","retcode"); } return(INIT_SUCCEEDED); }
Then every send goes through the gate first. Volume, stop loss and take profit are passed by reference. CTrade therefore sends the values that the gate approved, repairs included. The price is not fixed. SendMarket() passes 0.0, so CTrade takes the current price again when it sends, and that price can differ from the one OrderCheck() saw. The shadow check sits between the gate and CTrade and works on copies, so it can't change what is sent.
//+------------------------------------------------------------------+ //| SendMarket: market order through the gate when it is on | //+------------------------------------------------------------------+ void SendMarket(const ENUM_ORDER_TYPE type,double volume,double sl,double tp) { g_attempted[g_cur]++; sl=NormalizeDouble(sl,_Digits); tp=NormalizeDouble(tp,_Digits); if(InpUseGate) { ENUM_PREFLIGHT_CODE code=g_gate.CheckMarket(_Symbol,type,volume,sl,tp); if(code!=PF_OK) { CountBlocked(code); return; } } //--- the shadow works on copies, so the request sent stays the one the gate approved int shadow=-1; if(g_use_shadow) { double v=volume,s=sl,t=tp; shadow=(int)g_shadow.CheckMarket(_Symbol,type,v,s,t); } bool ok=(type==ORDER_TYPE_BUY) ? g_trade.Buy(volume,_Symbol,0.0,sl,tp) : g_trade.Sell(volume,_Symbol,0.0,sl,tp); CountResult(ok,shadow); }
The stress test left me with two habits. Treat PF_NO_CHANGE as "nothing to do" and keep it out of the error log, since it only means a trailing stop didn't move. And keep reading the retcode after sending. The gate lowers the number of rejections, but the server state can still change after any check, so the EA has to handle a refusal anyway.
Section 9: Checking the numbers outside MQL5
Counts printed by the same EA that produced them are easy to get wrong in the same way twice. So each stress run with InpRunTag set writes PreflightStress_<tag>.csv to the terminal's Common\Files folder, one line per request: time, step, gate code, shadow code, whether it was sent and the server retcode. preflight_crosscheck.py reads those files with the Python 3 standard library only and rebuilds the totals, the rejections per step and the shadow split. This is its output for the nine runs:
run requests blocked skipped sent rejected retcodes b0_r1 4415 892 442 3081 275 10015x113 10016x162 rejected by step: 2:74, 6:88, 8:113 b0_r2 4415 892 441 3082 277 10015x115 10016x162 rejected by step: 2:67, 6:95, 8:115 b0_r3 4416 892 442 3082 271 10015x103 10016x168 rejected by step: 2:73, 6:95, 8:103 b10_r1 4440 2224 442 1774 0 rejected by step: b10_r2 4440 2224 442 1774 0 rejected by step: b10_r3 4440 2224 442 1774 0 rejected by step: off_r1 4342 0 0 4342 2044 10014x446 10015x109 10016x1043 10019x446 rejected by step: 1:446, 2:73, 3:446, 4:446, 5:421, 6:103, 8:109 off_r2 4341 0 0 4341 2041 10014x446 10015x104 10016x1045 10019x446 rejected by step: 1:446, 2:71, 3:446, 4:446, 5:420, 6:108, 8:104 off_r3 4337 0 0 4337 2025 10014x446 10015x105 10016x1028 10019x446 rejected by step: 1:446, 2:69, 3:446, 4:446, 5:421, 6:92, 8:105 shadow b0_r1: blocked+rejected 275, blocked+accepted 1056, passed+rejected 0, passed+accepted 1750 share of server rejections the shadow would have stopped: 100.0% accepted requests it would have blocked per rejection stopped: 3.84 shadow b0_r2: blocked+rejected 277, blocked+accepted 1053, passed+rejected 0, passed+accepted 1752 share of server rejections the shadow would have stopped: 100.0% accepted requests it would have blocked per rejection stopped: 3.80 shadow b0_r3: blocked+rejected 271, blocked+accepted 1061, passed+rejected 0, passed+accepted 1750 share of server rejections the shadow would have stopped: 100.0% accepted requests it would have blocked per rejection stopped: 3.92
Every figure matches the totals the EA printed in the tester journal. The script assumes that retcodes 10008, 10009 and 10010 mean accepted and that every other retcode after a send is a rejection, which is also how the EA counts. It checks the arithmetic and the bookkeeping. It can't check that the server's verdicts were right, because the CSV holds only what the server answered.
Section 10: Limitations
The rules for a non-zero stops level and a non-zero freeze level are covered by unit tests alone. Those tests compare the rules with my reading of the documentation and the MetaQuotes article, not with a server. EURUSD on MetaQuotes-Demo has both levels at 0, so no server in my tests has confirmed them. A broker with a real freeze level is the next test this module needs.
The strict comparison refuses a level at exactly the stops distance. If a server accepts that level, the gate is one point more conservative than it has to be, and its ledger counts that request as blocked.
The own margin check is rough. It prices the new order alone. On a hedging account with a reduced hedged margin it can refuse an order that the real margin rules would allow. It knows nothing about exchange margin or offsets, which is why it doesn't run on netting and exchange accounts and why OrderCheck() still runs after it.
Rounding the volume down never raises the money at risk, but it still changes the position the EA gets and with it the exposure and the expected result per signal. An EA whose logic depends on the exact size should call SetRepair(false,...) and size its orders itself. The same goes for moving a take profit.
The session check treats a day with no sessions as closed, which is right for weekends but wrong for a custom symbol with no sessions defined at all. Sessions that cross midnight are covered by unit tests only.
The gate doesn't handle stop-limit orders, modification or deletion of pending orders, or partial closes. CheckPending() refuses stop-limit types with PF_BAD_REQUEST rather than guessing. When this article says every request, it means the supported ones listed in the Architecture section.
The retcode mapping keeps only unambiguous codes. A 10016 from OrderCheck() lands in PF_SERVER_CHECK, and the EA has to read LastRetcode() and LastDetail() to learn more.
CheckMarket() and CheckClose() read the price themselves and don't return it. An EA that sends with the price 0.0 gets a fresh price from CTrade, so OrderCheck() saw an equivalent request, not the identical one.
The price, the margin and the session can all change between the check and the moment the server sees the request. In live trading the delay also differs from the tester's random delay. Every stress figure comes from one symbol, one server, one timeframe and a request mix I made up. The figures show the mechanism and its order of magnitude. They are not an estimate of how often a real EA meets it, and the 3.8 ratio depends mainly on how close to the price an EA places its levels.
Conclusion
What I ended up with is one call in front of every supported trade request. It checks the broker's limits and fixes the small things it can fix without raising the volume or widening a stop loss unless the EA allows it. It runs OrderCheck() last, reads its retcode and counts every refusal by reason. The rules don't need a broker, so the 52 unit tests give the same answer on any account. They test my logic, and the stress test is where that logic meets a server.
I took two things from the stress test. Most refusals in my runs came from mistakes the EA could see in advance: an off-step volume, a wrong-side take profit, an oversized lot, a modification that changed nothing. The gate removed all of those. The remaining 271 to 277 per run came from the steps that place a level 1 or 2 points from the price, and my hypothesis is that the price moved during the delay. I didn't log the server's price, so this cause stays unconfirmed. On those same requests a 10-point buffer would have stopped every rejection, at the cost of 1,053 to 1,061 requests that the server accepted. How large to make that buffer depends on how close to the price an EA places its levels. I would start at 0, run a random-delay test with a shadow gate, and raise the buffer only if the rejections it removes are worth the requests it blocks.
To try it, unpack MQL5.zip into the terminal's data folder, or copy the files into the folders listed below. Run TestTradePreflight.mq5 on any chart. Then run PreflightStressEA.mq5 in the Strategy Tester with random delay, once with InpUseGate set to false and once with it set to true. Set InpShadowPoints and InpRunTag to get the shadow split and the CSV, then run preflight_crosscheck.py on the Common\Files folder.
Attached files
| # | Name | Folder | Description |
|---|---|---|---|
| 1 | PreflightTypes.mqh | MQL5/Include/TradePreflight/ | Result codes, SSymbolSpec, PreflightCodeText() |
| 2 | PreflightRules.mqh | MQL5/Include/TradePreflight/ | CPreflightRules: volume, tick grid, stops, pending price, freeze level and session rules |
| 3 | PreflightLedger.mqh | MQL5/Include/TradePreflight/ | CPreflightLedger: counters per result code and Report() |
| 4 | TradePreflight.mqh | MQL5/Include/TradePreflight/ | CTradePreflight: the gate an EA includes |
| 5 | TestTradePreflight.mq5 | MQL5/Scripts/TradePreflight/ | Verification script, 52 checks with PASS or FAIL, optional PNG of the results |
| 6 | PreflightStressEA.mq5 | MQL5/Experts/TradePreflight/ | Stress EA for the Strategy Tester: InpUseGate switches the gate, InpShadowPoints adds the shadow gate, InpRunTag writes the CSV |
| 7 | preflight_crosscheck.py | MQL5/Files/TradePreflight/ | Python 3 script that rebuilds the stress test tables from the CSV files |
| 8 | MQL5.zip | Unpack into the terminal's data folder | All seven files, with MQL5 as the root folder and a TradePreflight subfolder in Include, Experts, Scripts and Files |
Every file compiles with 0 errors and 0 warnings in MetaEditor build 6204. Tested on MetaTrader 5 build 6204, MetaQuotes-Demo, hedging account.
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.