preview
Designing a Strategy State Machine in MQL5: Replacing Nested If-Else Logic with Formal States

Designing a Strategy State Machine in MQL5: Replacing Nested If-Else Logic with Formal States

MetaTrader 5Trading systems |
129 0
Ushana Kevin Iorkumbul
Ushana Kevin Iorkumbul

Introduction

A typical Expert Advisor begins with a manageable OnTick() function. There is a condition to check whether a position is open, a condition to evaluate the entry signal, a condition to verify the spread is acceptable, and a condition to check whether the session is active. Four conditions, clearly readable, easily debugged.

After six months of feature additions, the same function grows to forty-three nested conditionals across 230 lines. To add a trailing stop, a new developer must first identify the exact condition set that indicates an open, profitable position before the first take-profit level. This requires reading the entire function, mapping the execution paths, and choosing an insertion point that does not break existing behavior. The probability of introducing a regression is not low.

The root problem is that the EA has implicit states — idle, looking for entry, in a trade, managing exit — but encodes them as scattered boolean flag combinations rather than explicit structural entities. With implicit state, state-handling code is interleaved with boundary-detection code. As a result, the function must answer two questions at each evaluation point: the current state and the action to perform. These are two distinct responsibilities, and combining them is what produces the fragility.

Monolithic OnTick() vs. Finite State Machine

Figure 1 – Monolithic OnTick() vs. Finite State Machine. The monolithic approach (left) evaluates every conditional branch on each tick, including unrelated paths like trailing-stop logic when no position is open. The FSM architecture (right) delegates execution to a single active state via a virtual dispatch, executing only the relevant code for the current strategy phase.

Beyond maintainability, there is also an execution cost. Every additional layer of nested conditionals adds more branches for the processor to evaluate, even when most of them are irrelevant to the current market situation. A finite state machine replaces that branching tree with a single dispatch to the active state, executing only the logic associated with the current strategy phase. The result is a cleaner architecture and a more predictable execution path.



Finite State Machine Architecture

The FSM implementation consists of five structural layers. The abstract interface IState defines the contract every concrete state must fulfill. The context class CStrategyContext owns the current state pointer and mediates all transitions. The concrete state classes (CIdleState, CEntryState, CInTradeState, CExitState) each implement IState and contain only the logic relevant to their operational phase. A separate implementation file StrategyContextImpl.mqh resolves the circular include dependency between CStrategyContext and the concrete states by providing the constructor, destructor, and concrete state accessor bodies after all type definitions are available. The Expert Advisor instantiates the context once and calls Update() on every tick.

The state pattern's central mechanism is the context pointer swap. CStrategyContext holds a single IState* member named m_current_state. When a transition is required, the current state calls context.SetState() with a pointer to the target state. On the next Update() call, the context dispatches to the new state's Evaluate() method. The context does not need to know which states exist or what their transition logic is. It only knows that it holds one current state and that Update() delegates to it.

FSM Class Structure

Figure 2 – FSM Class Structure. The IState interface defines the contract for all concrete states. CStrategyContext holds the current state pointer and provides SetState() for transitions. Four concrete states implement the strategy logic: idle scanning, order entry, active trade management, and position exit.

Include Chain and Circular Dependency Resolution

MQL5 requires all types to be fully defined before they can be instantiated. This creates a structural tension in the FSM architecture: CStrategyContext must forward-declare the concrete state classes so it can hold CIdleState*, CEntryState*, CInTradeState*, and CExitState* members, while the concrete state classes in States.mqh must see the full CStrategyContext declaration to call methods like ctx.GetSymbol() and ctx.SetState() in their method bodies.

The resolution is a three-file split. StrategyContext.mqh contains the CStrategyContext class declaration with forward declarations of the concrete states and provides only the scalar accessor method bodies, which require no concrete state type knowledge. States.mqh includes StrategyContext.mqh at its top so that the full CStrategyContext declaration is visible to every state method body. StrategyContextImpl.mqh includes StrategyContext.mqh and States.mqh in order. It then defines the constructor, destructor, and the four GetXxxState() accessors, which require fully defined concrete state types. The EA includes only StrategyContextImpl.mqh, which pulls the entire chain automatically.

The three-file structure that resolves the circular dependency is visible in the include directives themselves. StrategyContextImpl.mqh enforces the compilation order explicitly:

#include "StrategyContext.mqh"
#include "States.mqh"

StrategyContext.mqh begins with:

#include "IState.mqh"

//--- Forward declarations: full definitions are in States.mqh
class CIdleState;
class CEntryState;
class CInTradeState;
class CExitState;

The forward declarations allow CStrategyContext to declare pointer members of the concrete state types without requiring their full definitions. States.mqh then opens with:

#include "StrategyContext.mqh"

Because StrategyContext.mqh is protected by #ifndef STRATEGYCONTEXT_MQH , this second include is a no-op: the compiler skips the file body and proceeds with the already-resolved declaration. The concrete state class bodies in States.mqh can therefore call ctx.GetSymbol() , ctx.SetState() , and all other context methods because the full CStrategyContext declaration is already present. Only the constructor and GetXxxState() accessors require both CStrategyContext and the concrete state types to be fully defined. Therefore, they are isolated in StrategyContextImpl.mqh, which includes both prerequisites first.

The compilation order enforced by this structure is:

StrategyContextImpl.mqh
  └── StrategyContext.mqh
        └── IState.mqh           (enum + abstract class, no dependencies)
        CStrategyContext          (class declaration + scalar bodies only)
  └── States.mqh
        └── StrategyContext.mqh  (skipped — #ifndef guard already resolved)
        CIdleState, CEntryState, CInTradeState, CExitState (full definitions)
  Constructor, destructor, GetXxxState() bodies (all types fully defined here)

State Enumeration

enum ENUM_STRATEGY_STATE
{
   STATE_IDLE     = 0,  // No active position; awaiting valid entry signal
   STATE_ENTRY    = 1,  // Entry signal confirmed; order submission in progress
   STATE_IN_TRADE = 2,  // Position open; monitoring for exit conditions
   STATE_EXIT     = 3   // Exit condition triggered; closing position
};

State Transition Matrix

Current StateEvent TriggerTarget StateGuard Condition
STATE_IDLEEntry signal confirmedSTATE_ENTRYSpread within limit; no open positions with this magic
STATE_ENTRYOrder fill confirmedSTATE_IN_TRADEPositionsTotal() increased; position with correct magic found
STATE_ENTRYOrder rejected or timeoutSTATE_IDLERetcode not TRADE_RETCODE_DONE; or 10 ticks elapsed without fill
STATE_IN_TRADEStop loss hit externallySTATE_IDLEPositionsTotal() dropped to zero; no position with correct magic
STATE_IN_TRADEExit signal triggeredSTATE_EXITMA crossover reversal confirmed on current bar
STATE_EXITClose order confirmedSTATE_IDLEPositionsTotal() returned to zero
STATE_EXITClose order rejectedSTATE_IN_TRADEMax retry count reached; position still open



The IState Interface

IState defines three lifecycle methods. OnEnter() is called exactly once when the context transitions into a state. Evaluate() is called on every tick while the state is active. OnExit() is called exactly once immediately before the context transitions away from the state. This three-method contract separates initialization logic, per-tick logic, and cleanup logic into distinct, non-overlapping phases.

The separation has a practical consequence for debugging. If a position's stop loss level is incorrect, the investigation starts in CEntryState::OnEnter(), where the order is constructed, not in a search through a conditional tree of indeterminate length. If a trailing stop is updating incorrectly, the investigation starts in CInTradeState::Evaluate(). The state name in the Journal log immediately narrows the search space to one class and one method.

IState Method Interface Table

MethodReturn TypeAccessExecution Phase
OnEnter(CStrategyContext *ctx)voidpublic virtualCalled once on state activation; initialize state-local resources
Evaluate(CStrategyContext *ctx)void
public virtual
Called every tick while state is active; evaluate conditions and trigger transitions
OnExit(CStrategyContext *ctx)voidpublic virtual
Called once before deactivation; release state-local resources
GetStateId()ENUM_STRATEGY_STATEpublic virtual
Returns the enum identifier of the implementing state
GetStateName()stringpublic virtual
Returns a human-readable name for Journal logging

The forward declaration of CStrategyContext at the top of IState.mqh is what allows the method signatures to accept a CStrategyContext* parameter without the compiler requiring the full class definition at this point. The = 0 suffix on each method makes them pure virtual, meaning the compiler will reject any concrete subclass that does not override all five. The virtual destructor on the last line ensures that deleting a concrete state through an IState* pointer calls the correct destructor chain rather than producing undefined behavior.

//+------------------------------------------------------------------+
//|                                                       IState.mqh |
//| Abstract state interface for the strategy FSM.                   |
//| Every concrete state must implement all five virtual methods.    |
//+------------------------------------------------------------------+
#ifndef ISTATE_MQH
#define ISTATE_MQH

//--- Forward declaration: CStrategyContext is defined in StrategyContext.mqh
class CStrategyContext;

//+------------------------------------------------------------------+
//| Enumeration of execution states for the strategy FSM             |
//+------------------------------------------------------------------+
enum ENUM_STRATEGY_STATE
  {
   STATE_IDLE     = 0,  // No active position; awaiting valid entry signal
   STATE_ENTRY    = 1,  // Entry signal confirmed; order submission in progress
   STATE_IN_TRADE = 2,  // Position open; monitoring for exit conditions
   STATE_EXIT     = 3   // Exit condition triggered; closing position
  };

//+------------------------------------------------------------------+
//| Class IState                                                     |
//| Purpose: Interface defining actions within an execution state    |
//+------------------------------------------------------------------+
class IState
  {
public:
   //--- Lifecycle and state execution logic hooks
   virtual void               OnEnter(CStrategyContext *ctx) = 0;
   virtual void               Evaluate(CStrategyContext *ctx) = 0;
   virtual void               OnExit(CStrategyContext *ctx) = 0;

   //--- Metadata and state monitoring properties
   virtual ENUM_STRATEGY_STATE GetStateId(void) const = 0;
   virtual string             GetStateName(void) const = 0;

   //--- Virtual destructor ensuring clean polymorphic teardown
   virtual                   ~IState(void) {}
  };

#endif // ISTATE_MQH
//+------------------------------------------------------------------+



CStrategyContext Design

CStrategyContext is the owner and mediator of the state machine. It holds pointers to all four concrete state objects and manages their lifetimes from construction to destruction. It also carries the market data context that states need to make decisions: the current symbol, the MA indicator handles, and the last submitted order's ticket. Centralizing this data in the context eliminates the need for states to query the terminal independently, which simplifies the data flow and makes all context dependencies explicit through the ctx pointer parameter.

The SetState() method enforces the full transition lifecycle. It calls OnExit() on the departing state, updates m_current_state, records the entry tick, calls OnEnter() on the arriving state, and logs the transition to the Journal. This sequence is guaranteed to execute in the correct order regardless of which state initiates the transition, because all transitions go through the same method. A re-entry guard at the start of SetState() prevents the lifecycle from firing when the requested next state is the same object as the current state.

CStrategyContext Property Table

PropertyTypeAccessPurpose
m_current_stateIState*privateActive state pointer; target of every Update() dispatch
m_state_idleCIdleState*private
Pre-allocated idle state instance
m_state_entryCEntryState*private
Pre-allocated entry state instance
m_state_in_tradeCInTradeState*private
Pre-allocated in-trade state instance
m_state_exitCExitState*private
Pre-allocated exit state instance
m_symbolstringprivateTrading symbol bound at construction
m_magiculongprivateEA magic number for order identification
m_ma_fast_handleintprivateFast MA indicator handle
m_ma_slow_handleintprivateSlow MA indicator handle
m_last_ticketulongprivateTicket of the most recently submitted order
m_tick_countlongprivateCumulative tick counter for cooldown enforcement
m_state_entry_ticklongprivateTick count at which current state was entered
m_pending_directionintprivatePending trade direction: 1 = long, -1 = short, 0 = none

CStrategyContext Method Table

MethodReturn TypeAccessPurpose
CStrategyContext(string, ulong, int, int)publicImplemented in StrategyContextImpl.mqh; allocates four state instances, sets initial state to CIdleState
~CStrategyContext()publicImplemented in StrategyContextImpl.mqh; deletes all four state instances
Update()voidpublicIncrements tick counter; dispatches to current state's Evaluate()
SetState(IState*)voidpublicExecutes OnExit, pointer swap, entry tick record, OnEnter, Journal log
GetCurrentStateId()ENUM_STRATEGY_STATEpublicReturns current state's enum identifier
GetCurrentStateName()stringpublicReturns current state's name string
GetSymbol()stringpublicReturns bound symbol
GetMagic()ulongpublicReturns EA magic number
GetFastMAHandle()intpublicReturns fast MA indicator handle
GetSlowMAHandle()intpublicReturns slow MA indicator handle
GetLastTicket()ulongpublicReturns last submitted order ticket
SetLastTicket(ulong)voidpublicUpdates last submitted order ticket
GetTickCount()longpublicReturns cumulative tick counter
GetStateEntryTick()longpublicReturns tick count at which current state was entered
GetPendingDirection()intpublicReturns pending trade direction flag
SetPendingDirection(int)voidpublicSets pending trade direction flag
GetIdleState()CIdleState*publicImplemented in StrategyContextImpl.mqh; returns pointer to idle state
GetEntryState()CEntryState*publicImplemented in StrategyContextImpl.mqh; returns pointer to entry state
GetInTradeState()CInTradeState*public
Implemented in StrategyContextImpl.mqh; returns pointer to in-trade state
GetExitState() CExitState*publicImplemented in StrategyContextImpl.mqh; returns pointer to exit state

The SetState() method is the single choke point through which every transition in the machine must pass:

//+------------------------------------------------------------------+
//| Safe sequential processing of transitions between distinct states|
//+------------------------------------------------------------------+
void CStrategyContext::SetState(IState *next_state)
  {
   if(next_state == NULL)
      return;

   if(next_state == m_current_state)
      return;

   string from_name = m_current_state.GetStateName();
   string to_name   = next_state.GetStateName();

   m_current_state.OnExit(&this);
   m_current_state    = next_state;
   m_state_entry_tick = m_tick_count;
   m_current_state.OnEnter(&this);

   PrintFormat("[CStrategyContext] Transition: %s -> %s | Tick: %s",
               from_name, to_name, IntegerToString(m_tick_count));
  }

The two null and re-entry guards at the top prevent a malformed call from corrupting the state pointer or firing OnExit and OnEnter when no actual transition is needed. The from_name and to_name strings are captured before the pointer swap because after m_current_state = next_state the original state is no longer reachable through the context. Recording m_state_entry_tick at the moment of the swap gives every state a reliable reference point for timeout calculations such as the ten-tick fill guard in CEntryState. The PrintFormat call at the end produces the Journal trace that maps directly to the class structure described in the conclusion.

The Update() method that drives this on every tick is deliberately minimal:

//+------------------------------------------------------------------+
//| Drive current state evaluation sequence logic                    |
//+------------------------------------------------------------------+
void CStrategyContext::Update(void)
  {
   m_tick_count++;
   if(CheckPointer(m_current_state) == POINTER_DYNAMIC)
     {
      m_current_state.Evaluate(&this);
     }
  }

Incrementing m_tick_count before the dispatch means that when Evaluate() reads GetTickCount() or GetStateEntryTick(), the elapsed tick arithmetic is always consistent within the same tick handler call.



Concrete State Implementations

CIdleState

CIdleState monitors market conditions each tick and transitions to CEntryState when a valid entry signal is detected. The entry signal used here is a moving average crossover: the fast MA crossing above the slow MA triggers a long entry signal, and the fast MA crossing below the slow MA triggers a short entry signal. The state enforces two guard conditions before allowing the transition: the current spread must be within the configured maximum of 200 points, and there must be no existing position on the symbol with the EA's magic number.

OnEnter() resets the state-local MA tracking variables. OnExit() logs the detected signal direction from the context so that CEntryState can read it via ctx.GetPendingDirection() in its OnEnter() call on the same tick. 

CIdleState::Evaluate() is where the crossover detection and guard checks are concentrated:

//+------------------------------------------------------------------+
//| Core evaluation loop tracking technical indicator crossovers     |
//+------------------------------------------------------------------+
void CIdleState::Evaluate(CStrategyContext *ctx)
  {
   string symbol = ctx.GetSymbol();

   //--- Retrieve two bars of each MA to detect crossover
   double fast_buf[2], slow_buf[2];
   if(CopyBuffer(ctx.GetFastMAHandle(), 0, 0, 2, fast_buf) < 2 ||
      CopyBuffer(ctx.GetSlowMAHandle(), 0, 0, 2, slow_buf) < 2)
     {
      return;
     }

   double fast_curr = fast_buf[0];
   double slow_curr = slow_buf[0];
   double fast_prev = fast_buf[1];
   double slow_prev = slow_buf[1];

   //--- Guard: ensure no existing position with this magic number exists
   bool position_exists = false;
   for(int i = PositionsTotal() - 1; i >= 0; i--)
     {
      if(PositionGetSymbol(i) == symbol &&
         PositionGetInteger(POSITION_MAGIC) == (long)ctx.GetMagic())
        {
         position_exists = true;
         break;
        }
     }
   if(position_exists)
     {
      return;
     }

   //--- Guard: spread within tolerance (200 points maximum)
   long spread = SymbolInfoInteger(symbol, SYMBOL_SPREAD);
   if(spread > 200)
     {
      return;
     }

   //--- Detect Moving Average technical crossover signals
   bool long_signal  = (fast_prev <= slow_prev && fast_curr > slow_curr);
   bool short_signal = (fast_prev >= slow_prev && fast_curr < slow_curr);

   if(long_signal)
     {
      ctx.SetPendingDirection(1);
      ctx.SetState(ctx.GetEntryState());
     }
   else if(short_signal)
     {
      ctx.SetPendingDirection(-1);
      ctx.SetState(ctx.GetEntryState());
     }
  }

CopyBuffer is called with a count of two so that both the current bar value ([0]) and the previous bar value ([1]) are available in the same array. The crossover conditions compare the previous bar relationship against the current bar relationship: fast_prev <= slow_prev && fast_curr > slow_curr is true only when the lines were not yet crossed on the prior bar and have crossed on the current bar, avoiding repeated signals on subsequent ticks within the same crossover. The guard checks for an existing position and spread tolerance are evaluated after the buffer reads but before the signal check, so a failed buffer read returns early without consuming the guard logic needlessly. Calling ctx.SetPendingDirection() before ctx.SetState() ensures that by the time CEntryState::OnEnter() executes within the same SetState() call, the direction value is already available through ctx.GetPendingDirection().

CEntryState

CEntryState::OnEnter() constructs and submits the order immediately on activation. It does not wait for the next tick. This design choice reflects the reality that the signal was confirmed at the end of CIdleState::Evaluate() on the current tick, and delaying the submission by one tick introduces unnecessary slippage exposure. The order is submitted using MqlTradeRequest and MqlTradeResult with OrderSend(). Pre-flight validation uses a separate MqlTradeCheckResult struct passed to OrderCheck() before submission, consistent with the trade request architecture isolation requirement.

Evaluate() monitors the submission result on subsequent ticks. If PositionsTotal() has increased and a position with the correct magic number is found, the state transitions to CInTradeState. If ten ticks elapse without a confirmed fill, the state assumes the order was not executed and transitions back to CIdleState.

The order construction and submission happens entirely inside OnEnter(), not on the first tick of Evaluate():

//+------------------------------------------------------------------+
//| Submits order execution details upon state entry                 |
//+------------------------------------------------------------------+
void CEntryState::OnEnter(CStrategyContext *ctx)
  {
   m_order_submitted = false;
   m_retry_count     = 0;

   string symbol    = ctx.GetSymbol();
   int    direction = ctx.GetPendingDirection();

   double ask   = SymbolInfoDouble(symbol, SYMBOL_ASK);
   double bid   = SymbolInfoDouble(symbol, SYMBOL_BID);
   double point = SymbolInfoDouble(symbol, SYMBOL_POINT);

   double price = (direction == 1) ? ask : bid;
   double sl    = (direction == 1) ? price - 500 * point : price + 500 * point;
   double tp    = (direction == 1) ? price + 1000 * point : price - 1000 * point;

   MqlTradeRequest      request = {};
   MqlTradeResult       result  = {};
   MqlTradeCheckResult  check   = {};

   request.action       = TRADE_ACTION_DEAL;
   request.symbol       = symbol;
   request.volume       = 0.01;
   request.type         = (direction == 1) ? ORDER_TYPE_BUY : ORDER_TYPE_SELL;
   request.price        = price;
   request.sl           = sl;
   request.tp           = tp;
   request.deviation    = 10;
   request.magic        = ctx.GetMagic();
   request.comment      = "FSM Entry [" + IntegerToString(direction) + "]";
   request.type_filling = ORDER_FILLING_IOC;

   if(!OrderCheck(request, check))
     {
      PrintFormat("[CEntryState] OrderCheck failed. Retcode: %s. Reverting to idle.",
                  IntegerToString(check.retcode));
      ctx.SetPendingDirection(0);
      ctx.SetState(ctx.GetIdleState());
      return;
     }

   if(OrderSend(request, result))
     {
      ctx.SetLastTicket(result.deal);
      m_order_submitted = true;
      PrintFormat("[CEntryState] Order submitted. Deal: %s | Price: %s",
                  IntegerToString((int)result.deal),
                  DoubleToString(result.price, (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS)));
     }
   else
     {
      PrintFormat("[CEntryState] OrderSend failed. Retcode: %s. Reverting to idle.",
                  IntegerToString(result.retcode));
      ctx.SetPendingDirection(0);
      ctx.SetState(ctx.GetIdleState());
     }
  }

The MqlTradeCheckResult struct is passed to OrderCheck() before OrderSend() is called. This pre-flight validation catches margin, volume, and price constraint violations without consuming a broker round-trip, and if it fails the state transitions back to CIdleState immediately from within OnEnter(). The m_order_submitted flag is only set to true after OrderSend() returns successfully, so that Evaluate() on subsequent ticks does not begin counting the fill-confirmation timeout against a submission that never reached the broker. The ticket stored via ctx.SetLastTicket(result.deal) is available to other states through ctx.GetLastTicket() for order history lookups if needed.

Fill confirmation and the timeout fallback are handled by Evaluate():

//+------------------------------------------------------------------+
//| Monitors transaction confirmations or tracks expiration timeouts |
//+------------------------------------------------------------------+
void CEntryState::Evaluate(CStrategyContext *ctx)
  {
   if(!m_order_submitted)
     {
      return;
     }

   m_retry_count++;
   string symbol = ctx.GetSymbol();

   //--- Check for confirmed fill: position with correct magic exists
   for(int i = PositionsTotal() - 1; i >= 0; i--)
     {
      if(PositionGetSymbol(i) == symbol &&
         PositionGetInteger(POSITION_MAGIC) == (long)ctx.GetMagic())
        {
         Print("[CEntryState] Fill confirmed. Transitioning to CInTradeState.");
         ctx.SetState(ctx.GetInTradeState());
         return;
        }
     }

   //--- Timeout after 10 ticks without fill confirmation
   if(m_retry_count >= 10)
     {
      Print("[CEntryState] Fill timeout. Reverting to CIdleState.");
      ctx.SetPendingDirection(0);
      ctx.SetState(ctx.GetIdleState());
     }
  }

The early return when m_order_submitted is false prevents the retry counter from incrementing in the case where OnEnter() itself triggered a transition back to CIdleState. In that scenario Evaluate() will never be called again in this activation of the state, but the guard is present as a correctness guarantee.

CInTradeState

CInTradeState::Evaluate() performs three checks on every tick. First, it checks whether the position still exists by scanning PositionsTotal() for a position with the correct magic number. If the position has been closed externally by stop loss or manual intervention, it transitions directly to CIdleState without going through CExitState, since there is nothing to close. Second, it evaluates the exit signal conditions using the same MA crossover logic as CIdleState but in the reverse direction. Third, it updates a trailing stop if the profit threshold of 300 points has been crossed, adjusting the SL level using TRADE_ACTION_SLTP with OrderSend().

CInTradeState::Evaluate() performs position existence check, exit signal detection, and trailing stop adjustment in a single pass through the positions list:

//+------------------------------------------------------------------+
//| Analyzes market adjustments to apply active trailing mitigations |
//+------------------------------------------------------------------+
void CInTradeState::Evaluate(CStrategyContext *ctx)
  {
   string symbol = ctx.GetSymbol();
   ulong  magic  = ctx.GetMagic();
   bool   found  = false;

   //--- Locate the managed position
   for(int i = PositionsTotal() - 1; i >= 0; i--)
     {
      if(PositionGetSymbol(i) != symbol)
         continue;
      if(PositionGetInteger(POSITION_MAGIC) != (long)magic)
         continue;

      found = true;
      double open_price  = PositionGetDouble(POSITION_PRICE_OPEN);
      double current_sl  = PositionGetDouble(POSITION_SL);
      double current_bid = SymbolInfoDouble(symbol, SYMBOL_BID);
      double current_ask = SymbolInfoDouble(symbol, SYMBOL_ASK);
      double point       = SymbolInfoDouble(symbol, SYMBOL_POINT);
      long   pos_type    = PositionGetInteger(POSITION_TYPE);
      ulong  pos_ticket  = (ulong)PositionGetInteger(POSITION_TICKET);

      //--- Check MA crossover for exit signal
      double fast_buf[2], slow_buf[2];
      if(CopyBuffer(ctx.GetFastMAHandle(), 0, 0, 2, fast_buf) >= 2 &&
         CopyBuffer(ctx.GetSlowMAHandle(), 0, 0, 2, slow_buf) >= 2)
        {
         bool exit_long  = (pos_type == POSITION_TYPE_BUY  &&
                            fast_buf[0] < slow_buf[0] &&
                            fast_buf[1] >= slow_buf[1]);
         bool exit_short = (pos_type == POSITION_TYPE_SELL &&
                            fast_buf[0] > slow_buf[0] &&
                            fast_buf[1] <= slow_buf[1]);

         if(exit_long || exit_short)
           {
            Print("[CInTradeState] Exit signal detected. Transitioning to CExitState.");
            ctx.SetState(ctx.GetExitState());
            return;
           }
        }

      //--- Trailing stop update for long position
      if(pos_type == POSITION_TYPE_BUY)
        {
         double profit_points = (current_bid - open_price) / point;
         if(profit_points >= m_trailing_activation)
           {
            double new_sl = current_bid - m_trailing_distance * point;
            if(new_sl > current_sl + point)
              {
               MqlTradeRequest req = {};
               MqlTradeResult  res = {};
               req.action   = TRADE_ACTION_SLTP;
               req.symbol   = symbol;
               req.sl       = new_sl;
               req.tp       = PositionGetDouble(POSITION_TP);
               req.position = pos_ticket;
               if(OrderSend(req, res))
                 {
                  PrintFormat("[CInTradeState] Trailing SL updated to: %s",
                              DoubleToString(new_sl, (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS)));
                 }
              }
           }
        }

      //--- Trailing stop update for short position
      if(pos_type == POSITION_TYPE_SELL)
        {
         double profit_points = (open_price - current_ask) / point;
         if(profit_points >= m_trailing_activation)
           {
            double new_sl = current_ask + m_trailing_distance * point;
            if(new_sl < current_sl - point || current_sl == 0.0)
              {
               MqlTradeRequest req = {};
               MqlTradeResult  res = {};
               req.action   = TRADE_ACTION_SLTP;
               req.symbol   = symbol;
               req.sl       = new_sl;
               req.tp       = PositionGetDouble(POSITION_TP);
               req.position = pos_ticket;
               if(OrderSend(req, res))
                 {
                  PrintFormat("[CInTradeState] Trailing SL updated to: %s",
                              DoubleToString(new_sl, (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS)));
                 }
              }
           }
        }
      break;
     }

   //--- Position closed externally: transition to idle without exit state
   if(!found)
     {
      Print("[CInTradeState] Position closed externally. Transitioning to CIdleState.");
      ctx.SetState(ctx.GetIdleState());
     }
  }

The loop iterates backwards from PositionsTotal() - 1 to zero because removing a position mid-iteration from the front would shift indices. The found flag is set inside the loop and evaluated after it, so an externally closed position — stopped out or closed manually — causes a direct transition to CIdleState without routing through CExitState, since there is no open position to send a close order against. The exit signal check is placed before the trailing stop update so that a reversal signal immediately triggers a state transition without wasting a broker call on a stop level adjustment that is about to become irrelevant. The trailing stop update for a long position uses new_sl > current_sl + point as the movement guard: the new stop must be at least one point higher than the current stop before a TRADE_ACTION_SLTP request is submitted, preventing redundant order traffic on ticks where price has not moved enough to warrant a level change.

CExitState

CExitState::OnEnter() immediately submits a market close order for the open position. It uses TRADE_ACTION_DEAL with ORDER_TYPE_SELL for a long position and ORDER_TYPE_BUY for a short position, targeting the open position's volume exactly. Evaluate() monitors on subsequent ticks until PositionsTotal() confirms the position is closed, then transitions to CIdleState. If the close order fails, it retries on the following tick up to the m_max_attempts limit of five. After five failed attempts, it transitions back to CInTradeState to avoid abandoning an open position silently.

CExitState separates the close order submission into a private helper method so that both OnEnter() and the retry path in Evaluate() call the same logic without duplication:

//+------------------------------------------------------------------+
//| Actions performed when entering the Exit state                   |
//+------------------------------------------------------------------+
void CExitState::OnEnter(CStrategyContext *ctx)
  {
   m_close_attempts = 0;
   Print("[CExitState] Entered. Submitting close order.");
   SubmitClose(ctx);
  }

//+------------------------------------------------------------------+
//| Dispatches raw order liquidations into the trade execution queue |
//+------------------------------------------------------------------+
void CExitState::SubmitClose(CStrategyContext *ctx)
  {
   string symbol = ctx.GetSymbol();
   ulong  magic  = ctx.GetMagic();

   for(int i = PositionsTotal() - 1; i >= 0; i--)
     {
      if(PositionGetSymbol(i) != symbol)
         continue;
      if(PositionGetInteger(POSITION_MAGIC) != (long)magic)
         continue;

      long   pos_type   = PositionGetInteger(POSITION_TYPE);
      double volume     = PositionGetDouble(POSITION_VOLUME);
      ulong  pos_ticket = (ulong)PositionGetInteger(POSITION_TICKET);
      double price      = (pos_type == POSITION_TYPE_BUY)
                          ? SymbolInfoDouble(symbol, SYMBOL_BID)
                          : SymbolInfoDouble(symbol, SYMBOL_ASK);

      MqlTradeRequest request = {};
      MqlTradeResult  result  = {};

      request.action       = TRADE_ACTION_DEAL;
      request.symbol       = symbol;
      request.volume       = volume;
      request.type         = (pos_type == POSITION_TYPE_BUY) ? ORDER_TYPE_SELL : ORDER_TYPE_BUY;
      request.price        = price;
      request.deviation    = 10;
      request.magic        = magic;
      request.position     = pos_ticket;
      request.comment      = "FSM Exit";
      request.type_filling = ORDER_FILLING_IOC;

      m_close_attempts++;

      if(OrderSend(request, result))
        {
         PrintFormat("[CExitState] Close order submitted. Deal: %s",
                     IntegerToString((int)result.deal));
        }
      else
        {
         PrintFormat("[CExitState] Close order failed. Retcode: %s | Attempt: %s",
                     IntegerToString(result.retcode),
                     IntegerToString(m_close_attempts));
        }
      return;
     }
  }

//+------------------------------------------------------------------+
//| Verifies closing status and executes retries if required         |
//+------------------------------------------------------------------+
void CExitState::Evaluate(CStrategyContext *ctx)
  {
   string symbol = ctx.GetSymbol();
   ulong  magic  = ctx.GetMagic();

   //--- Check whether position has been closed
   bool still_open = false;
   for(int i = PositionsTotal() - 1; i >= 0; i--)
     {
      if(PositionGetSymbol(i) == symbol &&
         PositionGetInteger(POSITION_MAGIC) == (long)magic)
        {
         still_open = true;
         break;
        }
     }

   if(!still_open)
     {
      Print("[CExitState] Position confirmed closed. Transitioning to CIdleState.");
      ctx.SetPendingDirection(0);
      ctx.SetState(ctx.GetIdleState());
      return;
     }

   //--- Retry close if position still open and under attempt limit
   if(m_close_attempts < m_max_attempts)
     {
      Print("[CExitState] Position still open. Retrying close.");
      SubmitClose(ctx);
     }
   else
     {
      //--- Max retries exhausted: return to monitoring to avoid orphaned position
      Print("[CExitState] Max close attempts reached. Returning to CInTradeState.");
      ctx.SetState(ctx.GetInTradeState());
     }
  }

SubmitClose() increments m_close_attempts before calling OrderSend() rather than after, so a failed submission still counts against the attempt budget. The close order sets request.position to the open position's ticket, which is required when closing a specific position by ticket rather than relying on the broker to match by symbol and volume. The ORDER_FILLING_IOC filling policy mirrors the entry order configuration, ensuring consistent fill behavior across both submission directions. When Evaluate() finds that still_open is false, it calls ctx.SetPendingDirection(0) before transitioning, resetting the direction flag so that CIdleState begins its next activation in a clean state without carrying a stale direction value from the previous trade cycle. The fallback transition to CInTradeState after five failed close attempts is a deliberate safety choice: rather than returning to idle with an orphaned open position, the machine resumes monitoring the position so that a subsequent exit signal can trigger another close attempt on the next crossover.

State Transition Diagram

Figure 3 – State Transition Diagram. The FSM cycles through four states. Primary transitions (green) follow the normal order flow: idle → entry → in-trade → exit → idle. Recovery paths (red dashed) handle order rejection, timeout, or failed close attempts. External position closure (orange dashed) bypasses the exit state and returns directly to idle.



Overhead Analysis and Structural Constraints

Virtual dispatch cost: Each Update() call performs one virtual function dispatch through the m_current_state pointer. In MQL5, virtual dispatch follows a vtable lookup: the pointer's vtable address is read, the method's entry in that table is located, and execution branches to it. This is two memory reads and one indirect branch, typically completing in three to five clock cycles on a warm cache. The cost is fixed regardless of how many states exist in the machine and does not scale with the number of possible transitions, unlike a sequential if-else chain whose evaluation cost scales linearly with the number of conditions.

State object memory footprint: All four concrete state objects are heap-allocated once in CStrategyContext's constructor, which executes in StrategyContextImpl.mqh, and persist for the lifetime of the EA. There are no per-tick heap allocations in the state machine itself. The total memory occupied by the four state instances is small and deterministic.

Transition atomicity:The SetState() method executes OnExit() and OnEnter() synchronously within the same tick. This means that if CInTradeState triggers a transition to CExitState, the exit order submission in CExitState::OnEnter() occurs within the same OnTick() call that detected the exit condition, not on the following tick. This is the correct behavior for time-sensitive exits but requires that OnEnter() implementations be written with the understanding that they execute within a live tick handler.

Re-entry guard: The SetState() method checks whether the requested next state is the same object as the current state before executing the transition lifecycle. This prevents OnExit() and OnEnter() from firing on a no-op transition.

Absence of history states: This implementation does not support history states. The machine always enters a fresh state via OnEnter(). For EAs requiring composite states or history, the pattern would need to be extended with a state stack.

Single-Tick State Transition

Figure 4 – Single-Tick State Transition. When CInTradeState detects an exit signal inside Evaluate(), it calls ctx.SetState() to transition to CExitState. The departing state's OnExit() executes first, followed by the pointer swap, entry tick recording, and the arriving state's OnEnter(). The entire transition — including the OrderSend() call — completes synchronously within a single OnTick() execution frame.



Conclusion

The nested conditional tree is not merely an aesthetic problem. It is an architectural one. When state detection logic and state behavior logic occupy the same function body, every modification to one risks corrupting the other. Bugs introduced this way tend to manifest as edge-case failures in specific market conditions that are difficult to reproduce and nearly impossible to isolate by reading the code alone.

The state machine pattern resolves this by making the implicit explicit. Each operational phase of the EA becomes a named class with a defined entry point, a per-tick evaluation method, and a defined exit point. The transition triggers are documented in a table that can be read independently of the implementation. A failure in entry order submission is localized to CEntryState. A failure in trailing stop updates is localized to CInTradeState. The Journal log records every state transition with its tick timestamp, providing a complete execution trace that maps directly to the class structure.

The three-file split between StrategyContext.mqh, States.mqh, and StrategyContextImpl.mqh resolves the circular include dependency that arises when state method bodies need full knowledge of CStrategyContext while the context class needs to instantiate state objects. This is a structural constraint of MQL5's single-pass compiler rather than a design choice, but the resulting architecture is clean: StrategyContextImpl.mqh serves as the single entry point for any file that needs the complete FSM, and the EA's #include directive reflects this explicitly.

The overhead introduced is a single virtual dispatch per tick and a fixed memory allocation for four state objects at initialization. Both costs are bounded, predictable, and independent of the number of states or transitions in the machine.

As strategies evolve, the FSM approach keeps complexity localized, making extensions, testing, and debugging significantly more manageable than in a monolithic OnTick() implementation.


Programs used in the article:

#NameTypeDescription
1IState.mqhInclude FileAbstract state interface defining OnEnter(), Evaluate(), OnExit(), GetStateId(), and GetStateName() as pure virtual methods; contains ENUM_STRATEGY_STATE and a forward declaration of CStrategyContext
2StrategyContext.mqhInclude File
CStrategyContext class declaration with forward declarations of all four concrete state classes; provides Update(), SetState(), state query methods, and all scalar accessor bodies; does not include States.mqh
3StrategyContextImpl.mqhInclude File
Includes StrategyContext.mqh then States.mqh in dependency order; provides the CStrategyContext constructor, destructor, and the four GetXxxState() accessor implementations that require fully defined concrete state types; the EA includes only this file
4States.mqhInclude File
Includes StrategyContext.mqh so that all state method bodies can call ctx.GetSymbol(), ctx.SetState(), and other context methods; provides full implementations of CIdleState, CEntryState, CInTradeState, and CExitState
5StateMachineEA.mq5Demo EADemonstration EA including StrategyContextImpl.mqh as its sole include; binds CStrategyContext to real-time tick events, displays current state on chart, and prints state transition logs
6Strategy_State_Machine.zipZip ArchiveZip archive containing all the attached files and their paths relative to the terminal's root folder.
Attached files |
IState.mqh (1.97 KB)
States.mqh (20.76 KB)
StateMachineEA.mq5 (5.73 KB)
Features of Custom Indicators Creation Features of Custom Indicators Creation
Creation of Custom Indicators in the MetaTrader trading system has a number of features.
MQL5 Trading Tools (Part 38): Adding a Tabbed Settings Window for Editing Object Properties MQL5 Trading Tools (Part 38): Adding a Tabbed Settings Window for Editing Object Properties
We add a tabbed settings window opened from the ribbon and bound to the selected object. The tabs — Style, Text, Coordinates, and Visibility — are built from the same descriptor system, with scrolling, per-level rows, and shared color/width/style popovers. The article covers layout, rendering, interaction, and inline price/time and numeric editing. You get one place to edit every property with live preview and commit-or-discard on close.
Features of Experts Advisors Features of Experts Advisors
Creation of expert advisors in the MetaTrader trading system has a number of features.
From Cloud to Complex: The Vietoris-Rips Filtration in MQL5 From Cloud to Complex: The Vietoris-Rips Filtration in MQL5
We turn a price-embedded point cloud into a Vietoris–Rips filtration and its boundary matrix. The article enumerates vertices, edges, and triangles with filtration values, sorts them in entry order, and builds O(1) vertex/edge lookups. You get MQL5 classes CTDARips and CTDABoundary and a sparse Z/2 boundary suitable for the next-step persistence reduction.