On this page
Back to tutorials

Build Your First MT5 EA (MQL5): Step-by-Step Crossover Example

Hands-on: build a Moving Average crossover EA from scratch. From idea to code, then test in Strategy Tester. Clear entry/exit rules and logic you can extend.

📖 38 min read

📝 7,500 words

🏷️ MQL5 & Expert Advisors

Share this article:

Want to build a no-code strategy right now?

Create your free account in seconds and start building immediately.


Build Your First Expert Advisor — MA Crossover Step by Step

You have the environment set up and the MQL5 reference at hand. Now it's time to build a real trading EA: a Moving Average crossover strategy. By the end of this article you will have a complete MA crossover EA that compiles, runs in the Strategy Tester, and places real buy/sell orders — with clear entry/exit rules and logic you can extend. This tutorial takes you from idea to working code in eight focused steps.

All code follows the official MQL5 event handlers and trade functions documentation.


The Strategy — Moving Average Crossover

Idea: Use two Moving Averages — a fast one (e.g. 20 periods) and a slow one (e.g. 50 periods).

  • Buy signal: Fast MA crosses above the slow MA
  • Sell signal: Fast MA crosses below the slow MA

This is a classic trend-following approach. We trade in the direction of the crossover.


Step 1: Define the Logic

Before writing a single line of MQL5, you must crystallise your trading logic. A clear spec prevents bugs and makes debugging easier. For our MA crossover EA, we define every choice explicitly.

ComponentChoiceWhy This Choice
Fast MA20-period EMA, CloseEMA reacts faster than SMA to recent prices — ideal for crossover. Close is standard and widely used in backtests.
Slow MA50-period EMA, Close50 is a common "medium-term" period; it smooths noise and confirms trend direction.
EntryOne position at a time; new signal closes opposite and opens newKeeps risk simple: no multiple overlapping positions. When a new crossover occurs, we close any opposite position and open the new one.
ExitOpposite crossover closes the positionNo separate exit rule — the next opposite crossover both closes the current trade and opens the reverse.

Pro tip: Document your logic like this for every EA. Future you (and Google) will thank you when you revisit the code months later.


Step 2: Create the EA File

Create a new Expert Advisor from MetaEditor’s built-in template so you get the correct structure (OnInit, OnTick, OnDeinit) from the start.

  1. In MetaEditor, go to File — New and choose Expert Advisor (template).
  2. When prompted, name it MACrossoverEA and leave the default parameters — the wizard creates the skeleton.
  3. Save the file to MQL5\Experts. This folder is where MetaTrader 5 looks for EAs. If you save elsewhere, the EA will not appear in the Navigator.

Folder structure: MetaTrader 5 installs to a path like this:

C:\Users\YourName\AppData\Roaming\MetaQuotes\Terminal\...\MQL5\Experts

Your EA file MACrossoverEA.mq5 must live there (or in a subfolder) to be compiled and run.


Step 3: Inputs and Handles

We declare two kinds of variables: inputs (user-configurable) and handles (references to indicator instances).

Inputs appear in the EA properties dialog and the Strategy Tester. Users can change periods and lot size without editing code. The input keyword makes them persistent.

Handles are integer IDs returned by iMA(). They point to the indicator’s internal buffers. We create them once in OnInit() and reuse them in OnTick() via CopyBuffer(). Never create handles inside OnTick() — that would allocate new indicators on every tick and hurt performance.

The #property directives set metadata shown in MetaTrader (copyright, version). They are optional but good practice for professional EAs.

#property copyright "AlfaTactix Academy"
#property version   "1.00"

input int    InpFastPeriod = 20;     // Fast MA period
input int    InpSlowPeriod = 50;     // Slow MA period
input double InpLotSize    = 0.1;    // Lot size
input int    InpMagic      = 12345;  // Magic number

int g_fastHandle = INVALID_HANDLE;
int g_slowHandle = INVALID_HANDLE;

Step 4: OnInit — Create Indicator Handles

OnInit() runs once when the EA is attached to a chart (or when the Strategy Tester starts). Per the OnInit documentation, this is where you create indicator handles, validate inputs, and prepare resources.

Input validation: We reject invalid periods (e.g. fast ≥ slow, or zero/negative) and return INIT_PARAMETERS_INCORRECT. That stops the EA cleanly and shows an error in the Experts tab instead of crashing later.

iMA parameters: iMA(symbol, timeframe, period, shift, method, applied_price). We use PERIOD_CURRENT so the EA follows the chart’s timeframe, MODE_EMA for Exponential MA, and PRICE_CLOSE for close prices. If iMA() returns INVALID_HANDLE, the terminal logs an error and we return INIT_FAILED.

OnDeinit: When the EA is removed (chart closed, tester finished), OnDeinit() releases the handles with IndicatorRelease(). This frees memory and avoids leaks — a must when running many backtests.

int OnInit()
{
   if(InpFastPeriod <= 0 || InpSlowPeriod <= 0 || InpFastPeriod >= InpSlowPeriod)
   {
      Print("Invalid MA periods. Fast must be < Slow.");
      return(INIT_PARAMETERS_INCORRECT);
   }

   g_fastHandle = iMA(_Symbol, PERIOD_CURRENT, InpFastPeriod, 0, MODE_EMA, PRICE_CLOSE);
   g_slowHandle = iMA(_Symbol, PERIOD_CURRENT, InpSlowPeriod, 0, MODE_EMA, PRICE_CLOSE);

   if(g_fastHandle == INVALID_HANDLE || g_slowHandle == INVALID_HANDLE)
   {
      Print("Failed to create MA handles. Error: ", GetLastError());
      return(INIT_FAILED);
   }
   return(INIT_SUCCEEDED);
}

void OnDeinit(const int reason)
{
   if(g_fastHandle != INVALID_HANDLE) IndicatorRelease(g_fastHandle);
   if(g_slowHandle != INVALID_HANDLE) IndicatorRelease(g_slowHandle);
}

Step 5: OnTick — Detect Crossover and Trade

OnTick() is called on every price tick. That can be hundreds of times per candle. If we evaluate signals on every tick, we risk duplicate orders and multiple entries on the same bar. The solution: process signals only when a new bar forms.

New-bar check: We compare iTime(_Symbol, PERIOD_CURRENT, 0) (current bar's opening time) with a static variable lastBar. When they differ, a new bar has formed. We update lastBar and proceed; otherwise we return immediately. This is a lightweight and reliable pattern used in professional EAs.

Why three bars? To detect a crossover we need the state before and after the cross. With ArraySetAsSeries(buffer, true), index 0 = current bar, 1 = previous (closed) bar, 2 = bar before that. Crossover happens between bars 2 and 1:

  • Buy: On bar 1, fast MA is above slow (fast[1] > slow[1]), but on bar 2 it was below or equal (fast[2] <= slow[2]). That means the fast crossed above the slow.
  • Sell: Symmetrically, fast[1] < slow[1] and fast[2] >= slow[2] means the fast crossed below.

CopyBuffer: We request 3 elements (indices 0, 1, 2). If CopyBuffer returns less than 3, there isn't enough history yet — we return and wait for the next tick. On a fresh chart, the first bars may not have sufficient data, so this guard is essential.

Flow: On a buy signal we close any sell positions first, then open a buy if we don't already have one. Same for sell. This keeps our "one position per direction" rule.

void OnTick()
{
   static datetime lastBar = 0;
   datetime currentBar = iTime(_Symbol, PERIOD_CURRENT, 0);
   if(currentBar == lastBar) return;  // Wait for new bar
   lastBar = currentBar;

   double fast[], slow[];
   ArraySetAsSeries(fast, true);
   ArraySetAsSeries(slow, true);

   if(CopyBuffer(g_fastHandle, 0, 0, 3, fast) < 3) return;
   if(CopyBuffer(g_slowHandle, 0, 0, 3, slow) < 3) return;

   bool buySignal  = (fast[1] > slow[1]) && (fast[2] <= slow[2]);
   bool sellSignal = (fast[1] < slow[1]) && (fast[2] >= slow[2]);

   if(buySignal)
   {
      ClosePositions(POSITION_TYPE_SELL);
      if(CountPositions(POSITION_TYPE_BUY) == 0)
         OpenBuy();
   }
   else if(sellSignal)
   {
      ClosePositions(POSITION_TYPE_BUY);
      if(CountPositions(POSITION_TYPE_SELL) == 0)
         OpenSell();
   }
}

Step 6: OpenBuy and OpenSell — OrderSend

OrderSend() sends a trading request to the broker. You fill a MqlTradeRequest structure and optionally receive the result in MqlTradeResult.

Key fields explained:

  • action = TRADE_ACTION_DEAL — Immediate execution at market (not a pending order).
  • symbol — The trading pair; _Symbol is the chart’s symbol.
  • volume — Lot size; must comply with SYMBOL_VOLUME_MIN and SYMBOL_VOLUME_STEP.
  • typeORDER_TYPE_BUY or ORDER_TYPE_SELL.
  • price — For market orders: use Ask for buys (you pay the ask) and Bid for sells (you receive the bid). SymbolInfoDouble(_Symbol, SYMBOL_ASK/BID) returns the current price.
  • deviation — Max allowed slippage in points. 10 points is a common default; increase for volatile symbols if you see requotes.
  • magic — Your EA’s identifier. Use it to filter positions and orders in PositionSelect, OrderSelect, etc.

Error handling: If OrderSend() returns false, GetLastError() gives the reason (e.g. not enough margin, market closed, requote). Always log it for debugging.

void OpenBuy()
{
   MqlTradeRequest req = {};
   MqlTradeResult  res = {};

   req.action   = TRADE_ACTION_DEAL;
   req.symbol   = _Symbol;
   req.volume   = InpLotSize;
   req.type     = ORDER_TYPE_BUY;
   req.price    = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
   req.deviation = 10;
   req.magic    = InpMagic;

   if(!OrderSend(req, res))
      Print("OpenBuy failed: ", GetLastError());
}

void OpenSell()
{
   MqlTradeRequest req = {};
   MqlTradeResult  res = {};

   req.action   = TRADE_ACTION_DEAL;
   req.symbol   = _Symbol;
   req.volume   = InpLotSize;
   req.type     = ORDER_TYPE_SELL;
   req.price    = SymbolInfoDouble(_Symbol, SYMBOL_BID);
   req.deviation = 10;
   req.magic    = InpMagic;

   if(!OrderSend(req, res))
      Print("OpenSell failed: ", GetLastError());
}

Step 7: Close Positions and Count by Magic

In MetaTrader 5, an account can have positions from multiple EAs and manual trades. We must filter by symbol and magic number so our EA only manages its own positions.

CountPositions: Iterates over PositionsTotal() — the number of open positions. PositionGetTicket(i) returns the ticket; PositionGetString(POSITION_SYMBOL), PositionGetInteger(POSITION_MAGIC), and PositionGetInteger(POSITION_TYPE) identify each position. We count only positions matching our symbol, magic, and type (BUY or SELL). This prevents opening a second buy when we already have one.

ClosePositions: To close a position, we send a counter-order — a sell to close a buy, or a buy to close a sell. Set req.action = TRADE_ACTION_DEAL, req.position = ticket (the position to close), and req.type / req.price to the opposite side. req.volume must equal the position’s volume (we close the whole position). The magic ensures the closing order is attributed to our EA.

Why iterate backwards? When we modify the positions list (e.g. close one), indices can shift. Looping from PositionsTotal()-1 down to 0 avoids skipping or double-processing.

int CountPositions(ENUM_POSITION_TYPE type)
{
   int count = 0;
   for(int i = PositionsTotal() - 1; i >= 0; i--)
   {
      ulong ticket = PositionGetTicket(i);
      if(ticket == 0) continue;
      if(PositionGetString(POSITION_SYMBOL) != _Symbol) continue;
      if(PositionGetInteger(POSITION_MAGIC) != InpMagic) continue;
      if(PositionGetInteger(POSITION_TYPE) != type) continue;
      count++;
   }
   return count;
}

void ClosePositions(ENUM_POSITION_TYPE type)
{
   for(int i = PositionsTotal() - 1; i >= 0; i--)
   {
      ulong ticket = PositionGetTicket(i);
      if(ticket == 0) continue;
      if(PositionGetString(POSITION_SYMBOL) != _Symbol) continue;
      if(PositionGetInteger(POSITION_MAGIC) != InpMagic) continue;
      if(PositionGetInteger(POSITION_TYPE) != type) continue;

      MqlTradeRequest req = {};
      MqlTradeResult  res = {};
      req.action   = TRADE_ACTION_DEAL;
      req.position = ticket;
      req.symbol   = _Symbol;
      req.volume   = PositionGetDouble(POSITION_VOLUME);
      req.deviation = 10;
      req.magic    = InpMagic;
      req.type     = (type == POSITION_TYPE_BUY) ? ORDER_TYPE_SELL : ORDER_TYPE_BUY;
      req.price    = (type == POSITION_TYPE_BUY) ? SymbolInfoDouble(_Symbol, SYMBOL_BID) : SymbolInfoDouble(_Symbol, SYMBOL_ASK);

      OrderSend(req, res);
   }
}

Step 8: Compile and Test

1. Compile. In MetaEditor, press F7 (or Compile from the Build menu). The output panel shows errors and warnings. Fix any errors before proceeding. Warnings (e.g. unused variables) are optional but worth cleaning up for production code.

2. Open the Strategy Tester. In MetaTrader 5, go to View — Strategy Tester (or press Ctrl+R). The Tester panel appears at the bottom of the screen.

3. Configure the test. Select your EA (MACrossoverEA) from the dropdown. Choose a symbol (e.g. EURUSD), timeframe (H1 recommended for crossover strategies), and date range (e.g. the last 12 months for a meaningful backtest). Set the deposit and leverage if needed. Enable Visual mode if you want to watch the EA trade bar-by-bar.

4. Choose modeling quality. "Every tick" is most accurate but slowest. "1 minute OHLC" uses one tick per minute and is a good balance of speed and realism for daily/hourly strategies. "Open prices only" is fastest but least accurate — use only for quick sanity checks.

5. Run the test. Click Start. When finished, check the Results tab (profit, drawdown, trades) and the Journal tab (any Print() output and errors). The Graph tab shows equity curve. Use these to validate that your EA behaves as expected before considering Demo or Live deployment.


Summary — What You Built

PartRole
OnInitCreate iMA handles, validate inputs
OnDeinitRelease handles
OnTickNew-bar check, crossover logic, open/close
OpenBuy/OpenSellOrderSend with MqlTradeRequest
ClosePositionsClose by symbol + magic + type
CountPositionsAvoid duplicate positions

Full Code

Here is the complete EA in one block for copy-paste and verification. Assemble it in MetaEditor and save as MACrossoverEA.mq5 in MQL5\Experts.

//+------------------------------------------------------------------+
//|                                                MACrossoverEA.mq5 |
//|                                    MA Crossover Expert Advisor   |
//+------------------------------------------------------------------+
#property copyright "AlfaTactix Academy"
#property version   "1.00"

input int    InpFastPeriod = 20;     // Fast MA period
input int    InpSlowPeriod = 50;     // Slow MA period
input double InpLotSize    = 0.1;    // Lot size
input int    InpMagic      = 12345;  // Magic number

int g_fastHandle = INVALID_HANDLE;
int g_slowHandle = INVALID_HANDLE;

int OnInit()
{
   if(InpFastPeriod <= 0 || InpSlowPeriod <= 0 || InpFastPeriod >= InpSlowPeriod)
   {
      Print("Invalid MA periods. Fast must be < Slow.");
      return(INIT_PARAMETERS_INCORRECT);
   }
   g_fastHandle = iMA(_Symbol, PERIOD_CURRENT, InpFastPeriod, 0, MODE_EMA, PRICE_CLOSE);
   g_slowHandle = iMA(_Symbol, PERIOD_CURRENT, InpSlowPeriod, 0, MODE_EMA, PRICE_CLOSE);
   if(g_fastHandle == INVALID_HANDLE || g_slowHandle == INVALID_HANDLE)
   {
      Print("Failed to create MA handles. Error: ", GetLastError());
      return(INIT_FAILED);
   }
   return(INIT_SUCCEEDED);
}

void OnDeinit(const int reason)
{
   if(g_fastHandle != INVALID_HANDLE) IndicatorRelease(g_fastHandle);
   if(g_slowHandle != INVALID_HANDLE) IndicatorRelease(g_slowHandle);
}

void OnTick()
{
   static datetime lastBar = 0;
   datetime currentBar = iTime(_Symbol, PERIOD_CURRENT, 0);
   if(currentBar == lastBar) return;
   lastBar = currentBar;

   double fast[], slow[];
   ArraySetAsSeries(fast, true);
   ArraySetAsSeries(slow, true);
   if(CopyBuffer(g_fastHandle, 0, 0, 3, fast) < 3) return;
   if(CopyBuffer(g_slowHandle, 0, 0, 3, slow) < 3) return;

   bool buySignal  = (fast[1] > slow[1]) && (fast[2] <= slow[2]);
   bool sellSignal = (fast[1] < slow[1]) && (fast[2] >= slow[2]);

   if(buySignal)
   {
      ClosePositions(POSITION_TYPE_SELL);
      if(CountPositions(POSITION_TYPE_BUY) == 0) OpenBuy();
   }
   else if(sellSignal)
   {
      ClosePositions(POSITION_TYPE_BUY);
      if(CountPositions(POSITION_TYPE_SELL) == 0) OpenSell();
   }
}

void OpenBuy()
{
   MqlTradeRequest req = {};
   MqlTradeResult  res = {};
   req.action   = TRADE_ACTION_DEAL;
   req.symbol   = _Symbol;
   req.volume   = InpLotSize;
   req.type     = ORDER_TYPE_BUY;
   req.price    = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
   req.deviation = 10;
   req.magic    = InpMagic;
   if(!OrderSend(req, res)) Print("OpenBuy failed: ", GetLastError());
}

void OpenSell()
{
   MqlTradeRequest req = {};
   MqlTradeResult  res = {};
   req.action   = TRADE_ACTION_DEAL;
   req.symbol   = _Symbol;
   req.volume   = InpLotSize;
   req.type     = ORDER_TYPE_SELL;
   req.price    = SymbolInfoDouble(_Symbol, SYMBOL_BID);
   req.deviation = 10;
   req.magic    = InpMagic;
   if(!OrderSend(req, res)) Print("OpenSell failed: ", GetLastError());
}

int CountPositions(ENUM_POSITION_TYPE type)
{
   int count = 0;
   for(int i = PositionsTotal() - 1; i >= 0; i--)
   {
      ulong ticket = PositionGetTicket(i);
      if(ticket == 0) continue;
      if(PositionGetString(POSITION_SYMBOL) != _Symbol) continue;
      if(PositionGetInteger(POSITION_MAGIC) != InpMagic) continue;
      if(PositionGetInteger(POSITION_TYPE) != type) continue;
      count++;
   }
   return count;
}

void ClosePositions(ENUM_POSITION_TYPE type)
{
   for(int i = PositionsTotal() - 1; i >= 0; i--)
   {
      ulong ticket = PositionGetTicket(i);
      if(ticket == 0) continue;
      if(PositionGetString(POSITION_SYMBOL) != _Symbol) continue;
      if(PositionGetInteger(POSITION_MAGIC) != InpMagic) continue;
      if(PositionGetInteger(POSITION_TYPE) != type) continue;

      MqlTradeRequest req = {};
      MqlTradeResult  res = {};
      req.action   = TRADE_ACTION_DEAL;
      req.position = ticket;
      req.symbol   = _Symbol;
      req.volume   = PositionGetDouble(POSITION_VOLUME);
      req.deviation = 10;
      req.magic    = InpMagic;
      req.type     = (type == POSITION_TYPE_BUY) ? ORDER_TYPE_SELL : ORDER_TYPE_BUY;
      req.price    = (type == POSITION_TYPE_BUY) ? SymbolInfoDouble(_Symbol, SYMBOL_BID) : SymbolInfoDouble(_Symbol, SYMBOL_ASK);
      OrderSend(req, res);
   }
}

Troubleshooting

SymptomCauseFix
INVALID_HANDLE from iMASymbol or timeframe not available, or bad parametersEnsure the symbol is in Market Watch, chart is open, and periods are valid (fast < slow, both > 0).
Not enough money (134)Insufficient margin for the lot sizeReduce InpLotSize, increase deposit in Strategy Tester, or check leverage.
Requote (10004)Price moved before order filledIncrease req.deviation (e.g. 20–50 points) or accept requotes and retry.
EA does not open tradesAutoTrading off, or "Allow live trading" uncheckedClick AutoTrading in MT5 toolbar (Ctrl+E), and in EA Properties enable "Allow Algo Trading".
Trade not allowed (64)Broker or account restrictionsVerify the symbol allows trading, market is open, and the account permits automated trading.
Duplicate orders on same barNo new-bar check in OnTickAdd the lastBar / iTime check as in Step 5 — evaluate signals only once per candle.

Check the Experts tab in MetaTrader 5 for error codes and Print() messages. GetLastError() returns the numeric code; refer to MQL5 error constants.


Next Steps — Add Risk Management

This EA has no Stop Loss or Take Profit. In EA Risk Management, you'll add position sizing, SL/TP, trailing stops, and best practices to protect your account.

Bonus Tip: Want to build the same MA crossover EA without writing code? Try AlfaTactix Strategy Builder free — design moving average crossovers, RSI filters, and risk rules visually, then export production-ready MQL5 in minutes. Compare variants side-by-side before refining in MetaEditor. The same logic, zero typing.

Build your no-code trading strategy now — free

Create your account and start building a complete no-code strategy right now. Add indicators, filters, and risk rules, then export MQL5 in minutes.

Frequently Asked Questions

A fast MA (e.g. 20-period) and a slow MA (e.g. 50-period) are plotted. Buy when the fast MA crosses above the slow MA; sell when it crosses below. It is a classic trend-following approach.

OnTick() runs on every price tick. Checking for a new bar (e.g. comparing bar time) ensures you evaluate signals only once per candle — avoiding duplicate orders and multiple signals on the same bar.

Check PositionsTotal() and filter by magic number. Only open a new position if you have none (or your logic allows it). Use PositionGetInteger(POSITION_MAGIC) to identify your EA's positions.

Ensure AutoTrading is on, sufficient margin, symbol enabled in Market Watch, and the EA has "Allow live trading" checked in Properties. Check the Experts tab for error messages.

A magic number (ulong) identifies your EA's orders and positions. Use it to filter — e.g. close only positions opened by this EA. Set it in MqlTradeRequest.magic.

Set request.sl and request.tp in MqlTradeRequest before OrderSend(). Use NormalizeDouble() and respect SYMBOL_TRADE_STOPS_LEVEL. See our EA Risk Management article.

Build your no-code strategy now — free

Create Free Account