preview
Building a FIFO-Compliant Position Management Layer for NFA-Regulated MQL5 Accounts

Building a FIFO-Compliant Position Management Layer for NFA-Regulated MQL5 Accounts

MetaTrader 5 — Examples |
86 0
Tola Moses Hector
Tola Moses Hector

Table of Contents

  1. Introduction
  2. The Pipeline: What Goes In, What Comes Out
  3. Why FIFO Rejections Break Naive Exit Logic
  4. Two Compliance Strategies: Reject or Cascade
  5. CFifoPositionQueue—Ordering the Open Tickets
  6. Determining Eligibility and Building a Cascade
  7. CFifoComplianceGuard—Executing Compliant Closes
  8. FifoComplianceEA.mq5—Demonstrating the Problem on Purpose
  9. Verification With TestFifoCompliance.mq5
  10. Backtesting Notes
  11. Known Limitations
  12. Conclusion


Introduction

MQL5 exposes "AccountInfoInteger(ACCOUNT_FIFO_CLOSE)," a documented account property that reports directly whether an account requires closing positions in FIFO order—the oldest ticket on a symbol first, regardless of which ticket a strategy actually wants to reduce. A number of regulated brokers, most commonly those under NFA oversight in the United States, enforce this on hedging-mode accounts, and when they do, a close request against anything but the oldest ticket is rejected outright with "TRADE_RETCODE_FIFO_CLOSE" (10045).

This article is not legal or compliance advice. It makes no claim about which brokers enforce this rule or on what regulatory basis. That determination belongs to the trader's broker agreement and counsel. Programmatically, check "ACCOUNT_FIFO_CLOSE" instead of guessing. What this article does address is the mechanical consequence: an EA written without FIFO awareness will, on an account where this property is true, have some of its close requests rejected. CFifoPositionQueue and CFifoComplianceGuard address a specific problem: FIFO ordering among an EA's own tickets. They do not address every reason a close request can fail; section 7 states this explicitly.


The Pipeline: What Goes In, What Comes Out

Two classes divide this problem along a now-familiar line: one answers a pure ordering question with no live access at all, the other owns everything that actually touches the trade server.

  1. CFifoPositionQueue's "Refresh()" reads an EA's own open positions on one symbol—filtered by magic number, so it is structurally unable to see a position it does not own—into a plain array, sorted oldest-first by millisecond-precision open time. "IsFifoEligible()" and "BuildCascade()" then answer questions purely against that array: whether a given ticket is the oldest one present, and if not, the exact sequence of older tickets that would need to close first.
  2. CFifoComplianceGuard is the only class an EA calls directly. "RequestClose()" asks the queue whether a ticket is eligible. If not, it either rejects the request or executes the cascade produced by "BuildCascade()". Eligibility is re-checked immediately before each step because positions can change mid-cascade. "ExecuteSingleClose()" is where every actual "CTrade" call happens, where the broker's volume constraints get applied, and where the server's real result code—not just whether the call returned true—decides what happened. "AccountRequiresFifo()" sits apart from both: a direct, one-call answer to whether any of this machinery is even needed on the account the EA is running on.


Why FIFO Rejections Break Naive Exit Logic

Consider a simple pyramiding strategy: it adds to a position in the direction of a trend, producing several separate tickets on the same symbol opened at different times. A common exit rule for this kind of strategy is to trim the most recent add first—it has the least accumulated profit and the tightest stop, so cutting it loose first is a reasonable risk decision on its own terms.

On a FIFO-enforcing account, that request fails. The server does not care which ticket has the least profit or the tightest stop; it only accepts a close request against whichever ticket opened first. The naive strategy's "PositionClose()" call returns false, "ResultRetcode()" reports 10045, and unless the EA specifically checks for that code and reacts to it, the position it thought it had trimmed is still fully open. Repeated across many trades, this is not a rare edge case—it is the default outcome for any exit rule that was designed around profit, risk, or setup quality rather than around open order.

The fix is not to rewrite every exit rule to think about ticket age. It is to put a layer between the strategy's intent—"close this ticket"—and the trade server's requirement—"close the oldest ticket first"—and let that layer reconcile the two for whatever tickets the EA itself opened.


Two Compliance Strategies: Reject or Cascade

There is more than one defensible way to handle a close request against a non-eligible ticket—requiring confirmation before proceeding, or refusing outright for a position the account doesn't actually own, are both reasonable designs beyond the two this article implements. Of those two, which is appropriate depends on what the strategy actually wants when it makes the request.

  1. Strict reject—refuses the request outright and logs which ticket is actually eligible, leaving the decision to the calling code. This is the safer default when a non-eligible request usually signals a logic error worth surfacing rather than resolving silently—for example, a strategy that should never have more than one ticket open at a time, where a second ticket appearing at all means something upstream already went wrong.
  2. Auto-cascade—treats the request as an instruction to close every strictly-older ticket first, in full, and then act on the target. This is not the same action as closing the target alone—it is a deliberately larger one, and section 11 is explicit about what that changes. Auto-cascade is the appropriate mode only when a strategy genuinely wants the prerequisite tickets gone too, not merely out of the way—a pyramiding strategy trimming its newest add and accepting that the older adds go with it, for instance. "ENUM_FIFO_MODE" in the code below exposes both as a configurable input rather than picking one for every use case.
//+------------------------------------------------------------------+
//| Behavior when a requested ticket is not FIFO-eligible            |
//+------------------------------------------------------------------+
enum ENUM_FIFO_MODE
  {
   FIFO_MODE_STRICT_REJECT,  // refuse the close and report the correct ticket
   FIFO_MODE_AUTO_CASCADE    // close older same-symbol tickets first, then the requested one
  };

A second, independent choice governs what "FIFO-eligible" even means on a given account:

//+------------------------------------------------------------------+
//| Whether FIFO ordering is tracked per symbol as a whole, or       |
//| separately for each direction on that symbol                     |
//+------------------------------------------------------------------+
enum ENUM_FIFO_SCOPE
  {
   FIFO_SCOPE_SYMBOL_WIDE,   // one FIFO queue per symbol, regardless of direction
   FIFO_SCOPE_PER_DIRECTION  // a separate FIFO queue for each direction on the symbol
  };
MQL5 documentation does not state whether FIFO enforcement is symbol-wide or tracked separately for buy and sell positions. "FIFO_SCOPE_SYMBOL_WIDE" is the default and the more commonly assumed behavior; "FIFO_SCOPE_PER_DIRECTION" exists so that assumption is a configurable choice rather than a silent one, and section 5 shows exactly where it changes the queue's contents.


CFifoPositionQueue—Ordering the Open Tickets

Everything downstream depends on one correct ordering: which of a symbol's open tickets counts as oldest. "SFifoEntry" captures what that ordering needs, plus the two fields—"magic" and "type"—that section 7's filtering and scoping actually use rather than leaving unused.

//+------------------------------------------------------------------+
//| One open ticket's ordering-relevant fields                       |
//+------------------------------------------------------------------+
struct SFifoEntry
  {
   ulong             ticket;
   long              time_msc;      // POSITION_TIME_MSC — millisecond-precision open time
   double            volume;
   long              type;          // POSITION_TYPE_BUY or POSITION_TYPE_SELL
   long              magic;
  };

"time_msc" uses "POSITION_TIME_MSC" rather than the second-precision "POSITION_TIME" specifically to avoid manufacturing ties that do not need to exist—a pyramiding EA adding on every new bar can easily produce two tickets within the same second, and millisecond precision resolves almost all of those cleanly. The rare remaining tie, at the exact same millisecond, is broken by ascending ticket number as a plain, deterministic rule for keeping the ordering well-defined—not a claim about the order the server actually processed the two requests in, which is not something MQL5's documentation states.

//+------------------------------------------------------------------+
//| SortAscending                                                    |
//| Sorts by millisecond-precision open time first. Ties at the same |
//| millisecond — vanishingly rare, but possible — are broken by   |
//| ascending ticket purely as an arbitrary, deterministic final     |
//| rule so the ordering is always well-defined; this is a stable    |
//| tiebreak, not a documented claim about the order the server      |
//| actually processed the two requests in.                          |
//+------------------------------------------------------------------+
void CFifoPositionQueue::SortAscending(SFifoEntry &entries[]) const
  {
   int n = ArraySize(entries);
   for(int i = 1; i < n; i++)
     {
      SFifoEntry key = entries[i];
      int j = i - 1;
      while(j >= 0 &&
            (entries[j].time_msc > key.time_msc ||
             (entries[j].time_msc == key.time_msc && entries[j].ticket > key.ticket)))
        {
         entries[j + 1] = entries[j];
         j--;
        }
      entries[j + 1] = key;
     }
  }

"Refresh()" is the only method in the class that touches a live position, and it filters on two conditions before a position is even added to the array: the symbol, and an exact match on "magic." That second filter is a deliberate safety boundary, not an incidental detail—without it, a guard operating in auto-cascade mode could close a position it does not own, opened manually or by a different EA on the same symbol. Filtering by magic number makes that structurally impossible; what it does not do is guarantee server-side FIFO correctness when such foreign positions exist, which section 11 addresses directly rather than leaving implied.

//+------------------------------------------------------------------+
//| Refresh                                                          |
//| Rebuilds out_entries[] from live open positions on symbol owned  |
//| by magic, sorted oldest-first. Always re-reads rather than       |
//| maintaining incremental state, since position data is already    |
//| authoritative and a full rescan avoids any risk of stale         |
//| bookkeeping.                                                     |
//+------------------------------------------------------------------+
int CFifoPositionQueue::Refresh(const string symbol, const long magic, const long filter_type,
                                SFifoEntry &out_entries[]) const
  {
   ArrayResize(out_entries, 0);
   int total = PositionsTotal();
   int count = 0;

   for(int i = 0; i < total; i++)
     {
      ulong ticket = PositionGetTicket(i);
      if(ticket == 0)
         continue;
      if(PositionGetString(POSITION_SYMBOL) != symbol)
         continue;
      if(PositionGetInteger(POSITION_MAGIC) != magic)
         continue;

      long type = PositionGetInteger(POSITION_TYPE);
      if(filter_type >= 0 && type != filter_type)
         continue;

      ArrayResize(out_entries, count + 1);
      out_entries[count].ticket   = ticket;
      out_entries[count].time_msc = PositionGetInteger(POSITION_TIME_MSC);
      out_entries[count].volume   = PositionGetDouble(POSITION_VOLUME);
      out_entries[count].type     = type;
      out_entries[count].magic    = magic;
      count++;
     }

   SortAscending(out_entries);
   return count;
  }
"filter_type" is where "ENUM_FIFO_SCOPE" from section 4 actually takes effect: a negative value includes both directions in one queue, and a specific "POSITION_TYPE_BUY" or "POSITION_TYPE_SELL" restricts the queue to that direction alone. Rescanning on every call, rather than maintaining incremental state, costs a handful of "PositionGetTicket()" calls against however many tickets the symbol actually has open—for the small position counts a pyramiding strategy realistically produces, that cost is not worth optimizing away in exchange for the risk of the queue silently disagreeing with what the server holds.


Determining Eligibility and Building a Cascade

"IsFifoEligible()" and "BuildCascade()" are deliberately pure—they take an already-sorted "SFifoEntry" array and answer a question about it, with no live position access at all. That separation is what makes them directly testable with synthetic data in section 9, and it is also what keeps the ordering rules in one place: "CFifoComplianceGuard" never has to reimplement "what counts as oldest," it only ever asks these two methods.

//+------------------------------------------------------------------+
//| IsFifoEligible                                                   |
//| A ticket may be closed right now only if it is the oldest open   |
//| ticket in entries[] — index 0 once entries[] is sorted. Whether  |
//| that is also true server-side depends on entries[] actually      |
//| covering every position the server's own FIFO rule considers;    |
//| see Refresh()'s magic-filtering note and the article's Known     |
//| Limitations for what that does and does not guarantee.           |
//+------------------------------------------------------------------+
bool CFifoPositionQueue::IsFifoEligible(const SFifoEntry &entries[], ulong ticket) const
  {
   if(ArraySize(entries) == 0)
      return false;
   return (entries[0].ticket == ticket);
  }

"BuildCascade()" returns every ticket from the oldest up to and including the target, in chronological order—exactly the sequence that must be closed, in that order, before the target itself becomes eligible.

//+------------------------------------------------------------------+
//| Returns every ticket from the oldest up to and including         |
//| target_ticket, in chronological order — the exact sequence that  |
//| must be closed, in order, for target_ticket's close to become    |
//| FIFO-eligible. Returns -1 if target_ticket is not present.       |
//+------------------------------------------------------------------+
int CFifoPositionQueue::BuildCascade(const SFifoEntry &entries[], ulong target_ticket, ulong &cascade_out[]) const
  {
   int target_idx = FindIndex(entries, target_ticket);
   if(target_idx < 0)
     {
      ArrayResize(cascade_out, 0);
      return -1;
     }

   ArrayResize(cascade_out, target_idx + 1);
   for(int i = 0; i <= target_idx; i++)
      cascade_out[i] = entries[i].ticket;

   return target_idx + 1;
  }

Targeting the oldest ticket itself still returns a valid one-element cascade rather than a special-cased empty result—"CFifoComplianceGuard" treats "close directly" and "cascade of one" as the same code path, which is what keeps its own logic from needing a separate branch for the trivial case.


CFifoComplianceGuard—Executing Compliant Closes

Before anything else, "AccountRequiresFifo()" answers the question this entire article is about, directly, in one call—the account property itself, not an inference from whether a rejection has happened to occur yet:

bool AccountRequiresFifo() const { return((bool)AccountInfoInteger(ACCOUNT_FIFO_CLOSE)); }

"Configure()" sets up everything "RequestClose()" will need, including one correctness requirement that is easy to miss: CTrade's asynchronous mode has to be off. With it on, the result code "ExecuteSingleClose()" reads immediately after a trade call would not yet reflect the request's actual outcome, and this entire class depends on that result code being accurate at the point it is read.

//+------------------------------------------------------------------+
//| Configure                                                        |
//| Async mode is explicitly disabled — with it left on, the retcode |
//| ExecuteSingleClose() reads immediately after the trade call would|
//| not yet reflect the request's final outcome, which the rest of   |
//| this class's logic depends on being accurate at that point.      |
//+------------------------------------------------------------------+
void CFifoComplianceGuard::Configure(const ENUM_FIFO_MODE mode, const ulong magic, const ENUM_FIFO_SCOPE scope)
  {
   m_mode  = mode;
   m_scope = scope;
   m_magic = (long)magic;
   m_trade.SetExpertMagicNumber(magic);
   m_trade.SetAsyncMode(false);
  }

"ScopeFilterType()" is where "ENUM_FIFO_SCOPE" becomes the "filter_type" value "Refresh()" expects: -1 under symbol-wide scope, so both directions share one queue, or the target ticket's own direction under per-direction scope.

//+------------------------------------------------------------------+
//| ScopeFilterType                                                  |
//| Returns -1 (both directions) under symbol-wide scope, or the     |
//| ticket's own direction under per-direction scope.                |
//+------------------------------------------------------------------+
long CFifoComplianceGuard::ScopeFilterType(ulong ticket) const
  {
   if(m_scope == FIFO_SCOPE_SYMBOL_WIDE)
      return(-1);
   if(!PositionSelectByTicket(ticket))
      return(-1);
   return((long)PositionGetInteger(POSITION_TYPE));
  }

"RequestClose()" refreshes the queue, checks eligibility, and takes one of three paths: close directly, cascade through prerequisites, or reject.

//+------------------------------------------------------------------+
//| RequestClose                                                     |
//| Entry point for every close request in the EA. Resolves ticket   |
//| against the current FIFO ordering among this EA's own tickets on |
//| its symbol, and either executes directly, cascades through older |
//| prerequisite tickets, or rejects with a clear diagnostic —       |
//| depending on configured mode.                                    |
//+------------------------------------------------------------------+
bool CFifoComplianceGuard::RequestClose(ulong ticket, double volume)
  {
   if(!PositionSelectByTicket(ticket))
     {
      PrintFormat("FifoComplianceGuard: ticket %I64u is not an open position", ticket);
      return false;
     }
   string symbol    = PositionGetString(POSITION_SYMBOL);
   long   type_filter = ScopeFilterType(ticket);

   SFifoEntry entries[];
   int n = m_queue.Refresh(symbol, m_magic, type_filter, entries);
   if(n == 0)
      return false;

   if(m_queue.IsFifoEligible(entries, ticket))
      return ExecuteSingleClose(ticket, volume);

   if(m_mode == FIFO_MODE_STRICT_REJECT)
     {
      PrintFormat("FifoComplianceGuard: reject — ticket %I64u is not FIFO-eligible among this EA's own "
                  "tickets on %s, oldest is %I64u", ticket, symbol, entries[0].ticket);
      return false;
     }

   ulong cascade[];
   int cascade_n = m_queue.BuildCascade(entries, ticket, cascade);
   if(cascade_n <= 0)
      return false;

   for(int i = 0; i < cascade_n - 1; i++)
     {
      SFifoEntry fresh_entries[];
      int fresh_n = m_queue.Refresh(symbol, m_magic, type_filter, fresh_entries);
      if(fresh_n == 0 || !m_queue.IsFifoEligible(fresh_entries, cascade[i]))
        {
         PrintFormat("FifoComplianceGuard: cascade aborted — ticket %I64u is no longer the FIFO-eligible "
                     "next ticket, the position set changed mid-cascade", cascade[i]);
         return false;
        }

      if(!ExecuteSingleClose(cascade[i], 0.0))
        {
         PrintFormat("FifoComplianceGuard: cascade aborted — failed to close prerequisite ticket %I64u",
                     cascade[i]);
         return false;
        }
     }

   return ExecuteSingleClose(ticket, volume);
  }

The re-check immediately before each cascade step is new, and worth explaining rather than leaving implicit: "BuildCascade()" is called once, from a single snapshot, but the position set it describes can be stale by the time the loop reaches its second or third step—another EA, a manual close, or a stop-loss fill between steps would all change it. Re-running "Refresh()" and "IsFifoEligible()" before each individual close catches that directly, aborting cleanly rather than proceeding against an ordering that may no longer be correct.

"ExecuteSingleClose()" is where every actual trade request happens, and it is built around one correction worth stating plainly: a "CTrade" call returning "true" means the request was accepted for processing, not that it completed successfully. The server's actual result code is what decides that, and this method reads it unconditionally—never only when the call's own return value was false.

//+------------------------------------------------------------------+
//| ExecuteSingleClose                                               |
//| volume <= 0 closes the full remaining position; otherwise a      |
//| normalized partial close is requested. The server's actual       |
//| result code is always inspected here, regardless of whether the  |
//| CTrade call itself returned true — a true return only means the  |
//| request was accepted for processing, not that it completed       |
//| successfully, so treating it as the final answer would let a     |
//| failed close silently pass as done.                              |
//+------------------------------------------------------------------+
bool CFifoComplianceGuard::ExecuteSingleClose(ulong ticket, double volume)
  {
   if(!PositionSelectByTicket(ticket))
     {
      PrintFormat("FifoComplianceGuard: ticket %I64u is no longer an open position — skipping", ticket);
      return false;
     }

   if(volume > 0.0)
     {
      string symbol         = PositionGetString(POSITION_SYMBOL);
      double current_volume = PositionGetDouble(POSITION_VOLUME);
      double vol_step        = SymbolInfoDouble(symbol, SYMBOL_VOLUME_STEP);
      double vol_min          = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MIN);
      double vol_max          = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MAX);

      double normalized = NormalizeCloseVolume(volume, current_volume, vol_step, vol_min, vol_max);
      if(normalized <= 0.0)
        {
         PrintFormat("FifoComplianceGuard: requested volume for ticket %I64u normalizes to zero — "
                     "nothing to close", ticket);
         return false;
        }

      if(normalized >= current_volume)
         m_trade.PositionClose(ticket);
      else
         m_trade.PositionClosePartial(ticket, normalized);
     }
   else
      m_trade.PositionClose(ticket);

   uint retcode = m_trade.ResultRetcode();

   if(retcode == TRADE_RETCODE_DONE)
      return true;

   if(retcode == TRADE_RETCODE_FIFO_CLOSE)
     {
      m_fifo_confirmed_by_rejection = true;
      PrintFormat("FifoComplianceGuard: server rejected ticket %I64u with FIFO_CLOSE (10045) — "
                  "this account enforces FIFO (AccountRequiresFifo() should already have said so)", ticket);
      return false;
     }

   if(retcode == TRADE_RETCODE_DONE_PARTIAL)
     {
      PrintFormat("FifoComplianceGuard: ticket %I64u only partially filled (retcode %u, DONE_PARTIAL) — "
                  "treated as not complete, since this guard's cascade logic assumes a prerequisite "
                  "close is fully done before moving to the next one", ticket, retcode);
      return false;
     }

   PrintFormat("FifoComplianceGuard: close failed ticket=%I64u retcode=%u (%s)",
               ticket, retcode, m_trade.ResultRetcodeDescription());
   return false;
  }
Three things happen here that did not before. First, the requested volume is normalized before any trade call is made. Second, "TRADE_RETCODE_DONE_PARTIAL" is handled as a distinct case. Third, all other outcomes are logged with their retcode and description.


FifoComplianceEA.mq5—Demonstrating the Problem on Purpose

The demo EA does two things deliberately: it pyramids into a position on a fixed bar interval, building up exactly the multi-ticket situation FIFO rules apply to, and it periodically requests a trim on the newest ticket rather than the oldest—the naive request pattern described in section 3.

//+------------------------------------------------------------------+
//| Deliberately requests a close on the NEWEST ticket rather than   |
//| the oldest — the naive request pattern a strategy written        |
//| without FIFO awareness would produce (for example, a rule that   |
//| trims "the most recent add" first). This is exactly what         |
//| exercises CFifoComplianceGuard's reject/cascade behavior; the    |
//| point is to show the guard correcting a wrong request, not to    |
//| endorse writing naive exit logic.                                |
//+------------------------------------------------------------------+
void AttemptNaiveTrim(void)
  {
   ulong newest = NewestOwnTicket();
   if(newest == 0)
      return;

   PrintFormat("FifoComplianceEA: requesting trim on newest ticket %I64u (mode=%s, scope=%s)",
               newest, EnumToString(InpFifoMode), EnumToString(InpFifoScope));

   if(g_guard.RequestClose(newest, InpTrimVolume))
      PrintFormat("FifoComplianceEA: trim request on ticket %I64u resolved successfully", newest);
   else
      PrintFormat("FifoComplianceEA: trim request on ticket %I64u did not complete", newest);
  }

This is not an example of good exit design—deliberately requesting the wrong ticket is the point. It is the same request a strategy with no FIFO awareness at all would generate, and running it through "g_guard.RequestClose()" instead of a raw "CTrade::PositionClose()" call is what turns a request the server would reject into one it accepts, without the pyramiding logic itself needing to know anything about ticket order.

"OnInit()" prints "AccountRequiresFifo()" directly, every run, before anything else happens—the one question worth answering with certainty rather than inference, right where a reader checking the Experts log will see it first.

Pyramiding only produces multiple separate tickets on a hedging-mode account—on a netting account, each additional buy in the same direction merges into the symbol's single existing position instead. "OnInit()" checks "ACCOUNT_MARGIN_MODE" and logs a note rather than failing outright when the account is not in hedging mode, since the guard itself is harmless either way: with only one ticket ever open, that ticket is always the oldest, and every close request resolves trivially.


Verification With TestFifoCompliance.mq5

Because "SortAscending()," "IsFifoEligible()," "BuildCascade()," and "NormalizeCloseVolume()" all operate on plain data with no live position access, "TestFifoCompliance.mq5" builds every scenario from synthetic data. This proves the pure ordering and volume-rounding logic is correct; it does not, and cannot, prove that "RequestClose()" and "ExecuteSingleClose()" behave correctly against a real trade server, since that depends on retcodes, timing, and account behavior no synthetic test can reproduce—section 10 is explicit about where that second kind of confirmation actually has to come from.

The tie-break test is worth reading closely, since it is checking behavior that would be easy to get wrong silently: three entries sharing an identical millisecond timestamp, inserted in a deliberately scrambled ticket order, must come out sorted purely by ascending ticket number.

//+------------------------------------------------------------------+
//| TestSortTieBreakByTicket                                         |
//| Same-millisecond opens — vanishingly rare, but not impossible —  |
//| break ties by ascending ticket purely as a deterministic rule,   |
//| not as a claim about the server's own processing order.          |
//+------------------------------------------------------------------+
void TestSortTieBreakByTicket(void)
  {
   Print("--- Sort tie-break tests ---");

   CFifoPositionQueue queue;
   SFifoEntry entries[3];
   long same_time_msc = 5000;
   entries[0] = MakeEntry(300, same_time_msc);
   entries[1] = MakeEntry(100, same_time_msc);
   entries[2] = MakeEntry(200, same_time_msc);

   queue.SortAscending(entries);

   ASSERT(entries[0].ticket == 100, "tie-break: lowest ticket first among equal time_msc");
   ASSERT(entries[1].ticket == 200, "tie-break: middle ticket second among equal time_msc");
   ASSERT(entries[2].ticket == 300, "tie-break: highest ticket last among equal time_msc");
  }

"NormalizeCloseVolume()" gets its own set of checks, each against a hand-computable expectation: a requested volume that needs rounding down to the broker's step, one that exceeds what remains open and must clamp to that instead, one that rounds below the broker's minimum and must return zero rather than a near-zero close, and one that exceeds the symbol's maximum.

//+------------------------------------------------------------------+
//| TestNormalizeCloseVolumeRoundsDownToStep                         |
//+------------------------------------------------------------------+
void TestNormalizeCloseVolumeRoundsDownToStep(void)
  {
   Print("--- Normalize close volume: rounds down to step tests ---");

   CFifoComplianceGuard guard;
// requested 0.345, step 0.01 -> floor(34.5)*0.01 = 0.34
   double result = guard.NormalizeCloseVolume(0.345, 1.0, 0.01, 0.01, 500.0);
   ASSERT_CLOSE(result, 0.34, 0.0001, "normalize: 0.345 rounds down to 0.34 at a 0.01 step");
  }
The cascade tests cover three shapes deliberately: a target in the middle of the queue, a target that is already the oldest ticket, and an unknown target ticket. Together with the eligibility and volume-normalization tests, this is ten checks that exercise the pure logic "RequestClose()" and "ExecuteSingleClose()" are built on—not those two methods themselves, which only a live or demo account can actually exercise.


Backtesting Notes

The Strategy Tester will exercise the pyramiding logic and the cascade decision-making correctly on a hedging-mode test account—which prerequisite closes in which order, under which mode. What it will not do, in a typical backtest, is actually return "TRADE_RETCODE_FIFO_CLOSE" from the simulated trade server, since that rejection is specific to the real trade servers of brokers that enforce the rule, nor does a clean backtest exercise the full range of real-world retcodes "ExecuteSingleClose()" now distinguishes—a requote, a frozen level, a "DONE_PARTIAL" fill, and the rest are conditions a generic simulated server does not generate. Treat a clean backtest as confirmation that the guard's decision logic behaves correctly. Do not treat it as confirmation of behavior against a real server. That confirmation requires a demo or live account, where "AccountRequiresFifo()" and, if it occurs, "IsFifoConfirmedByRejection()" reflect the account's actual behavior.

Run on a hedging-mode account ("ACCOUNT_MARGIN_MODE_RETAIL_HEDGING") to actually see multiple tickets accumulate; a netting-mode test will still complete without errors, but every position on the symbol collapses to one ticket and there is nothing for the cascade logic to demonstrate.


Known Limitations

Scoping "Refresh()" to the EA's own magic number is a safety boundary against the guard ever closing a position it does not own—not a complete guarantee of server-side FIFO correctness. If other positions exist on the same symbol outside this EA's magic number—opened manually, or by a different EA—the trade server's own FIFO rule still considers them, and this guard has no visibility into them at all. A ticket this guard believes is FIFO-eligible among its own tickets can still be rejected by the server if an older, foreign position exists on the same symbol; "ExecuteSingleClose()" retcode handling is what catches that when it happens, not something "Refresh()" filtering prevents in advance. Whether to extend this to an account-wide queue that looks past magic number entirely—and accepts the ownership risk that comes with cascading through positions this guard did not open—is a design decision deliberately left to the reader rather than defaulted into this code.

Whether a broker's FIFO enforcement actually spans both directions on a symbol together, or scopes separately by direction, is not documented by MQL5 either way; "FIFO_SCOPE_SYMBOL_WIDE" is this article's default assumption, not a confirmed fact about any specific broker, and "FIFO_SCOPE_PER_DIRECTION" exists specifically so that assumption can be changed rather than silently relied on.

Auto-cascade mode closes every prerequisite ticket in full before acting on the target. This is not equivalent to closing the target alone—it realizes the P&L, ends the holding period, and changes the risk distribution of every ticket it closes along the way, which is a different outcome from the single-ticket action the original request named, not a larger version of the same one. Confirming that is the intended tradeoff, for a given strategy, matters more than the mechanics of how the cascade executes.

"AccountRequiresFifo()" is the documented, authoritative signal for whether an account enforces this rule, and should be checked before relying on anything else in this article. "IsFifoConfirmedByRejection()" is weaker and secondary: it reports only that the server has, at some point during this run, actually rejected a request with retcode 10045—direct evidence the enforcement is live, but its absence proves nothing, since it may simply mean no request has yet been misordered against a server that would have rejected it.

The related "no simultaneous opposite-direction positions" constraint, sometimes bundled with FIFO under the same regulatory umbrella, is out of scope here. That rule is typically enforced by the server itself rejecting or netting an opposing order at submission time, rather than something a close-side compliance layer manages.

"Refresh()" rescans every open position on the symbol on every call, including once per cascade step now that each step re-verifies before executing. This is deliberately simple and correct for the position counts a pyramiding strategy realistically produces, but it is not the right choice for an account managing hundreds of simultaneous tickets on one symbol.

Re-verifying immediately before each cascade step narrows the race window between building the cascade and executing it, but does not eliminate it—the position set can still change in the instant between that re-check and the trade call itself. The guard's abort-on-first-failure behavior keeps that scenario safe rather than silently wrong, but it does not retry automatically; a caller that needs guaranteed eventual completion should re-issue the request after a failure rather than assume one call is sufficient.


Conclusion

TRADE_RETCODE_FIFO_CLOSE is the rejection code returned when a trade server detects a FIFO violation, while ACCOUNT_FIFO_CLOSE allows an EA to check whether the account requires FIFO compliance before submitting a close request.

In this article, we implemented a FIFO-compliant position management layer consisting of two components. CFifoPositionQueue provides deterministic ordering of the EA's positions by opening time, with ticket numbers used to resolve ties. CFifoComplianceGuard translates a strategy's close request into FIFO-aware execution, either rejecting an out-of-order request in strict mode or closing prerequisite positions in sequence in auto-cascade mode. The guard rechecks position ordering during execution and evaluates actual server return codes rather than assuming that a submitted request succeeded.

Unit tests validate the ordering, eligibility, cascade construction, and volume normalization logic. However, the implementation is limited to positions belonging to the EA, while broker FIFO restrictions may also apply to positions opened manually or by other EAs. Cascade execution stops at the first failure, and broker-specific behavior must be verified on an appropriate demo or live account.

The resulting architecture separates trading decisions from execution constraints, allowing strategies to express which position they intend to close while a dedicated layer handles FIFO compliance. This makes position management more predictable, testable, and easier to diagnose without hiding the limitations imposed by the trading server.


Programs Used in This Article:

# Name Type Description
1 FifoComplianceEA.mq5 Demo EA Demo EA that pyramids into a position and deliberately exercises the guard with mis-ordered close requests.
2 FifoPositionQueue.mqh Include File Chronological per-symbol ticket ordering and pure FIFO-eligibility logic.
3 FifoComplianceGuard.mqh Include File The execution layer that resolves close requests into FIFO-compliant sequences.
4 TestFifoCompliance.mq5 Script Verification script covering sort ordering, tie-breaking, eligibility, and cascade construction.
5 FifoCompliance.zip Zip Archive Archive with all files that can be unpacked into the terminal installation directory, and all files will be located in the required places.
Attached files |
MQL5.zip (11.96 KB)
Weibull Reliability Analysis for Strategy Lifespan: Estimating When an EA Is Likely to "Fail" Weibull Reliability Analysis for Strategy Lifespan: Estimating When an EA Is Likely to "Fail"
We implement a native Weibull maximum-likelihood fit in MQL5 and use it to model strategy wear-out from trade history. Failures are defined as the rolling profit factor dropping below a floor, producing lifetimes in trades. The article wires a survival-based governor into an EA and a companion indicator, so you can estimate conditional survival over the next N trades and apply explicit retirement thresholds with cooldown.
Uncertainty as a Model (Part 6): Multiple Regression and Diagnostics Uncertainty as a Model (Part 6): Multiple Regression and Diagnostics
A practical introduction to multiple regression with deterministic factors for financial time series. Topics covered include the formulation and matrix form of ordinary least squares (OLS), residual diagnostics, EDA/CDA, R-squared and adjusted R-squared, the F-test, RESET, the Breusch–Pagan test, VIF, and Cook’s distance. Techniques for working with regressors are demonstrated: dummy variables, nonlinear transformations, and interaction terms. Examples involving trends, days of the week, and news help select factors and build models suitable for forecasting.
Building a Two-Pole Momentum Oscillator in MQL5 Building a Two-Pole Momentum Oscillator in MQL5
We build a Two‑Pole Momentum Oscillator in MQL5 that measures deviation from a 25‑period SMA, normalizes it by its mean and standard deviation, and smooths the result with two EMA‑style passes. The code defines inputs, buffers, warm‑up and recalculation logic, signal rules, and colorized plots, and derives dynamic targets from the 100‑period average range. It helps detect momentum shifts around the zero line and display price levels tied to recent volatility.
How to Implement R/S Analysis and the Hurst Indicator in MQL5 How to Implement R/S Analysis and the Hurst Indicator in MQL5
The Hurst exponent indicator for MQL5 is based on R/S analysis using OLS regression in log-log space. The theoretical basis is the findings of Gatheral–Jaisson–Rosenbaum (2014), according to which volatility follows fractional Brownian motion with H ≈ 0.10. The indicator estimates H within a moving window, identifies anti-persistent (H < 0.3), neutral, and trending (H > 0.5) market regimes, colors the line, and triggers an alert upon a regime change, helping you choose a strategy type and manage risk.