Designing a Strategy State Machine in MQL5: Replacing Nested If-Else Logic with Formal States
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.

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.

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 State | Event Trigger | Target State | Guard Condition |
|---|---|---|---|
| STATE_IDLE | Entry signal confirmed | STATE_ENTRY | Spread within limit; no open positions with this magic |
| STATE_ENTRY | Order fill confirmed | STATE_IN_TRADE | PositionsTotal() increased; position with correct magic found |
| STATE_ENTRY | Order rejected or timeout | STATE_IDLE | Retcode not TRADE_RETCODE_DONE; or 10 ticks elapsed without fill |
| STATE_IN_TRADE | Stop loss hit externally | STATE_IDLE | PositionsTotal() dropped to zero; no position with correct magic |
| STATE_IN_TRADE | Exit signal triggered | STATE_EXIT | MA crossover reversal confirmed on current bar |
| STATE_EXIT | Close order confirmed | STATE_IDLE | PositionsTotal() returned to zero |
| STATE_EXIT | Close order rejected | STATE_IN_TRADE | Max 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
| Method | Return Type | Access | Execution Phase |
|---|---|---|---|
| OnEnter(CStrategyContext *ctx) | void | public virtual | Called 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) | void | public virtual | Called once before deactivation; release state-local resources |
| GetStateId() | ENUM_STRATEGY_STATE | public virtual | Returns the enum identifier of the implementing state |
| GetStateName() | string | public 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
| Property | Type | Access | Purpose |
|---|---|---|---|
| m_current_state | IState* | private | Active state pointer; target of every Update() dispatch |
| m_state_idle | CIdleState* | private | Pre-allocated idle state instance |
| m_state_entry | CEntryState* | private | Pre-allocated entry state instance |
| m_state_in_trade | CInTradeState* | private | Pre-allocated in-trade state instance |
| m_state_exit | CExitState* | private | Pre-allocated exit state instance |
| m_symbol | string | private | Trading symbol bound at construction |
| m_magic | ulong | private | EA magic number for order identification |
| m_ma_fast_handle | int | private | Fast MA indicator handle |
| m_ma_slow_handle | int | private | Slow MA indicator handle |
| m_last_ticket | ulong | private | Ticket of the most recently submitted order |
| m_tick_count | long | private | Cumulative tick counter for cooldown enforcement |
| m_state_entry_tick | long | private | Tick count at which current state was entered |
| m_pending_direction | int | private | Pending trade direction: 1 = long, -1 = short, 0 = none |
CStrategyContext Method Table
| Method | Return Type | Access | Purpose |
|---|---|---|---|
| CStrategyContext(string, ulong, int, int) | — | public | Implemented in StrategyContextImpl.mqh; allocates four state instances, sets initial state to CIdleState |
| ~CStrategyContext() | — | public | Implemented in StrategyContextImpl.mqh; deletes all four state instances |
| Update() | void | public | Increments tick counter; dispatches to current state's Evaluate() |
| SetState(IState*) | void | public | Executes OnExit, pointer swap, entry tick record, OnEnter, Journal log |
| GetCurrentStateId() | ENUM_STRATEGY_STATE | public | Returns current state's enum identifier |
| GetCurrentStateName() | string | public | Returns current state's name string |
| GetSymbol() | string | public | Returns bound symbol |
| GetMagic() | ulong | public | Returns EA magic number |
| GetFastMAHandle() | int | public | Returns fast MA indicator handle |
| GetSlowMAHandle() | int | public | Returns slow MA indicator handle |
| GetLastTicket() | ulong | public | Returns last submitted order ticket |
| SetLastTicket(ulong) | void | public | Updates last submitted order ticket |
| GetTickCount() | long | public | Returns cumulative tick counter |
| GetStateEntryTick() | long | public | Returns tick count at which current state was entered |
| GetPendingDirection() | int | public | Returns pending trade direction flag |
| SetPendingDirection(int) | void | public | Sets pending trade direction flag |
| GetIdleState() | CIdleState* | public | Implemented in StrategyContextImpl.mqh; returns pointer to idle state |
| GetEntryState() | CEntryState* | public | Implemented in StrategyContextImpl.mqh; returns pointer to entry state |
| GetInTradeState() | CInTradeState* | public | Implemented in StrategyContextImpl.mqh; returns pointer to in-trade state |
| GetExitState() | CExitState* | public | Implemented 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.

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.

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:
| # | Name | Type | Description |
|---|---|---|---|
| 1 | IState.mqh | Include File | Abstract state interface defining OnEnter(), Evaluate(), OnExit(), GetStateId(), and GetStateName() as pure virtual methods; contains ENUM_STRATEGY_STATE and a forward declaration of CStrategyContext |
| 2 | StrategyContext.mqh | Include 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 |
| 3 | StrategyContextImpl.mqh | Include 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 |
| 4 | States.mqh | Include 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 |
| 5 | StateMachineEA.mq5 | Demo EA | Demonstration 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 |
| 6 | Strategy_State_Machine.zip | Zip Archive | Zip archive containing all the attached files and their paths relative to the terminal's root folder. |
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.
Features of Custom Indicators Creation
MQL5 Trading Tools (Part 38): Adding a Tabbed Settings Window for Editing Object Properties
Features of Experts Advisors
From Cloud to Complex: The Vietoris-Rips Filtration in MQL5
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use