A Beginner’s Guide to Building a Simple MA Crossover/Crossunder Trading Bot with MT5 and Python

Watch on YouTube: MA Crossover Bot in Python for MT5

As part of our series about using MetaTrader 5 (MT5) and Python, we’re going to develop a strategy that provides an excellent opportunity to demonstrate the key functionalities of the MetaTrader 5 package. This strategy, known as the crossover/crossunder, is intuitive and simple, yet very convenient for showcasing different features of MT5’s Python integration.

In the world of algorithmic trading, moving averages are one of the most used tools for identifying trends and making trading decisions. The crossover/crossunder strategy leverages two moving averages: a fast-moving average (shorter period) and a slow-moving average (longer period).

  1. Crossover:
    • Occurs when the fast-moving average crosses above the slow-moving average.
    • This signals a potential bullish trend and acts as a trigger to enter a buy (long) position.
  2. Crossunder:
    • Happens when the fast-moving average crosses below the slow-moving average.
    • This signals a potential bearish trend and acts as a trigger to enter a sell (short) position.

This strategy is simple, yet effective, as it helps traders identify trend reversals and capitalize on momentum.

In this blog post, we’ll walk you through a Python-based trading bot developed to automate this strategy using MetaTrader 5 (MT5). The bot fetches live market data, calculates moving averages, detects crossovers and crossunders, and places trades accordingly.

Ensure that you use a demo account when testing the strategy!

Explaining the Trading Bot Functions

The code is structured into distinct functions, each handling a specific aspect of the trading bot’s operation. This modular approach offers several key benefits:

  1. Clarity and Readability: Breaking the code into smaller, self-contained functions makes it easier to understand and maintain. Each function has a clear purpose, which simplifies troubleshooting and modifications.
  2. Reusability: Functions like create_df (data preparation) and place_order (order execution) can be reused across different strategies or trading scenarios without rewriting the code.
  3. Scalability: By organizing the code into functions, you can easily expand the bot by adding new functionalities, such as additional signal detection methods or risk management rules.
  4. Debugging and Testing: Testing individual functions in isolation simplifies the debugging process. You can validate each function independently before integrating it into the main trading loop.

This structure not only enhances the overall efficiency of the trading bot but also makes it more adaptable to evolving trading needs.

Initialization Function

The first step is to import the required libraries and establish a connection between Python and the MetaTrader 5 trading platform.

import MetaTrader5 as mt5
import pandas as pd
import time
def initialize_mt5(login, server, password):
    if not mt5.initialize(login=login, server=server, password=password):
        print(f"Failed to initialize MT5: {mt5.last_error()}")
        quit()
    else:
        print("Connected to MT5")

What It Does:

  • Imports the MetaTrader5 package, as well as the pandas and time libraries.
  • Connects to the MT5 platform accepting the account credentials as parameters.

If the connection fails, the bot terminates with an error message.

Data Preparation Function

The create_df function retrieves market data for the specified trading symbol, calculates moving averages, and organizes the data in a pandas DataFrame.

def create_df(symbol, window, ma_fast, ma_slow, timeframe=mt5.TIMEFRAME_M1):
    rates = mt5.copy_rates_from_pos(symbol, timeframe, 1, window)
    if rates is None:
        print(f"Failed to fetch rates for {symbol}: {mt5.last_error()}")
        return pd.DataFrame()
    df = pd.DataFrame(rates)
    df["time"] = pd.to_datetime(df["time"], unit="s")
    df.set_index("time", inplace=True)
    df["MA_10"] = df["close"].rolling(ma_fast).mean()  #10-period SMA
    df["MA_50"] = df["close"].rolling(ma_slow).mean()  # 50-period SMA
    return df.dropna()

What It Does:

  • In rates = mt5.copy_rates_from_pos(symbol, timeframe, 1, window), we extract historical data starting from index 1 (not 0, which is the current, incomplete bar) to ensure that the crossover/crossunder is confirmed and fully formed. The default timeframe is set to 1-minute, but it can be adjusted to other options such as 5-minutes, 15-minutes, 1-hour, 1-day, and more.
  • Calculates the fast (MA_10) and slow (MA_50) moving averages using close prices.
  • Returns a clean DataFrame for analysis.

Signal Detection Functions

These functions detect crossovers and crossunders to generate buy or sell signals.

Crossover Function:

def long_signal(df):
    return df["MA_10"].iloc[-1] > df["MA_50"].iloc[-1] and df["MA_10"].iloc[-2] <= df["MA_50"].iloc[-2]

Crossunder Function:

def short_signal(df):
    return df["MA_10"].iloc[-1] < df["MA_50"].iloc[-1] and df["MA_10"].iloc[-2] >= df["MA_50"].iloc[-2]

What They Do:

  • long_signal: Detects when the fast-moving average crosses above the slow-moving average, signaling a potential upward trend.
  • short_signal: Detects when the fast-moving average crosses below the slow-moving average, signaling a potential downward trend.

Order Placement Function

The place_order function places buy or sell orders with specified stop-loss (SL) and take-profit (TP) levels.

def place_order(symbol, order_type, lot, sl, tp, price):
    request = {
        "action": mt5.TRADE_ACTION_DEAL,
        "symbol": symbol,
        "volume": lot,
        "type": order_type,
        "price": price,
        "sl": sl,
        "tp": tp,
        "deviation": 10,
        "magic": 101,
        "comment": "Python script order",
        "type_time": mt5.ORDER_TIME_GTC,
        "type_filling": mt5.ORDER_FILLING_IOC
    }
    result = mt5.order_send(request)
    if result.retcode != mt5.TRADE_RETCODE_DONE:
        print(f"Order failed: {result.retcode}, Error: {mt5.last_error()}")
    else:
        print(f"Order placed: Ticket {result.order}, Price {result.price}, SL {sl}, TP {tp}")

Logic of the Function

  1. Constructing the request Object:
    • A dictionary called request is created to define the trade’s parameters, such as the symbol, order type, volume, stop-loss, and take-profit.
  2. Key Elements in the Request:
    • action: Specifies the action type, which is a market execution order (mt5.TRADE_ACTION_DEAL).
    • deviation: Sets the maximum acceptable slippage in points. If the price deviates more than this, the order will not be executed.
    • magic: A custom identifier for the order, useful for distinguishing bot-placed orders from manual ones.
    • type_filling: Determines how the order is filled. mt5.ORDER_FILLING_IOC ensures the order is executed immediately or canceled. Check the symbol specifications in your Market Watch to confirm the filling method. It might be IOC or another type, such as FOK. Adjust the code accordingly.
  3. Sending the Order:
    • The mt5.order_send(request) function sends the request to the MT5 server.
  4. Result Handling:
    • If the order is successfully placed (retcode == mt5.TRADE_RETCODE_DONE), the function prints the details, including the ticket number, price, SL, and TP.
    • If the order fails, the function prints the error code and retrieves additional information using mt5.last_error().

Existing Position Check

This function ensures that the bot doesn’t place duplicate orders for the same symbol and type (buy or sell).

def existing_position(symbol, order_type):
    positions = mt5.positions_get(symbol=symbol)
    if positions:
        for position in positions:
            if position.type == order_type:
                return True
    return False

What It Does:

  • Checks if there is already an open position of the specified type for the symbol.
  • Prevents redundant orders.

The Main Function

The main function orchestrates the bot’s operations, tying everything together.

def main(symbol):
    data = create_df(symbol, 100, 10, 50)
    if data.empty:
        print(f"No data for {symbol}")
        return
    if long_signal(data) and not existing_position(symbol, mt5.ORDER_TYPE_BUY):
        print("Long signal generated, no active long positions!")
        sl = mt5.symbol_info_tick(symbol).ask * 0.99
        tp = mt5.symbol_info_tick(symbol).ask * 1.01
        place_order(symbol, mt5.ORDER_TYPE_BUY, 0.1, sl, tp, mt5.symbol_info_tick(symbol).ask)
    elif short_signal(data) and not existing_position(symbol, mt5.ORDER_TYPE_SELL):
        print("Short signal generated, no active short positions!")
        sl = mt5.symbol_info_tick(symbol).bid * 1.01
        tp = mt5.symbol_info_tick(symbol).bid * 0.99
        place_order(symbol, mt5.ORDER_TYPE_SELL, 0.1, sl, tp, mt5.symbol_info_tick(symbol).bid)
    else:
        print("Trading rules not satisfied!")

What It Does:

The main function is the core component of the trading bot, handling the workflow of data analysis, signal detection, and order execution while adhering to predefined trading rules. Here’s a step-by-step explanation:

  • Fetch Historical Data: The function starts by retrieving historical price data for the specified symbol and calculates two moving averages: a fast-moving average (MA_10) and a slow-moving average (MA_50). This info is stored into variable named data using the  create_df(symbol, 100, 10, 50) function. If data retrieval fails or the DataFrame is empty, the function prints an error message and exits.
  • Detect Trading Signals: The function checks for a bullish crossover (buy signal) or a bearish crossunder (sell signal) using the long_signal and short_signal functions
  • Place Orders: If a signal is detected and no existing position is open in the signal direction, the bot places a buy or sell order with appropriate stop-loss (SL) and take-profit (TP) levels:
    • Stop-Loss: Protects against excessive losses by setting a threshold (e.g., 1% below/above the current price).
    • Take-Profit: Locks in profits by setting a threshold (e.g., 1% above/below the current price).
    • The place_order function handles the actual trade execution.
  • Skip Action if No Conditions are Met: If neither a valid signal is detected nor trading conditions are satisfied, the function prints a message and skips execution.

Why This Structure is Effective

  • Clarity: Clear separation between data retrieval, signal detection, and order placement makes the function easy to understand and maintain.
  • Error Prevention: The use of existing_position ensures that redundant or conflicting trades are avoided.
  • Flexibility: The function parameters (e.g., moving average periods, SL/TP levels) are easily adjustable.
  • Efficiency: By confirming crossovers and crossunders with completed bars, the function reduces false signals and ensures reliable trade execution.

Code Execution

This final part of the code is the entry point for executing the trading bot. It initializes the trading environment, sets parameters for runtime, and ensures the bot’s operations run within a defined timeframe.

if __name__ == "__main__":
    # Initialize MetaTrader 5 connection with account credentials
    initialize_mt5(login=52021216, server="ICMarketsEU-Demo", password="G3wt9SY&4wBKJd")
    symbol = "EURUSD"  # Define the trading symbol
    # Run the trading logic in a loop for 1 hour
    timeout = time.time() + (60 * 60)  # 1-hour runtime = 60 seconds 60 times
    while time.time() <= timeout:
        main(symbol)  # Execute the main trading logic
        time.sleep(3)  # Wait 3 seconds before the next iteration
    
    mt5.shutdown()  # Cleanly shut down the MetaTrader 5 connection

Inside the while loop the main function is executed for predefined time. Pauses for 3 seconds between iterations to reduce unnecessary server requests and avoid overloading the platform.

Trading Logic Limitations

This trading logic is intentionally oversimplified and designed purely for demonstrational purposes. The 1% take-profit (TP) and stop-loss (SL) levels are arbitrarily chosen and not based on analytical reasoning or market conditions. Additionally, the code does not prevent holding two opposite positions simultaneously. It also lacks robust error handling or logging, which are critical for real-world algorithmic trading.

Despite these limitations, the bot serves as a solid starting point for understanding the crossover strategy and provides a clear example of how MetaTrader 5 and Python can be integrated effectively to automate trading strategies.

Full Code

Here is the full code. Ensure that algorithmic trading settings are enabled in your MetaTrader 5 platform.

# to be used only on demo account!
import MetaTrader5 as mt5
import pandas as pd
import time
# Initialize a connection to MetaTrader 5 with account credentials
def initialize_mt5(login, server, password):
    if not mt5.initialize(login=login, server=server, password=password):
        print(f"Failed to initialize MT5: {mt5.last_error()}")  # Print error if initialization fails
        quit()
    else:
        print("Connected to MT5")  # Confirmation message if connection succeeds
# Create a DataFrame with historical data and calculate moving averages
def create_df(symbol, window, ma_fast, ma_slow, timeframe=mt5.TIMEFRAME_M1):
    # Fetch historical rates data for the specified symbol and timeframe
    rates = mt5.copy_rates_from_pos(symbol, timeframe, 1, window)
    if rates is None:
        print(f"Failed to fetch rates for {symbol}: {mt5.last_error()}")  # Print error if data fetch fails
        return pd.DataFrame()
    
    # Convert the rates into a pandas DataFrame
    df = pd.DataFrame(rates)
    df["time"] = pd.to_datetime(df["time"], unit="s")  # Convert timestamp to datetime
    df.set_index("time", inplace=True)  # Set the 'time' column as the index
    df["MA_10"] = df["close"].rolling(ma_fast).mean()  # Calculate fast moving average (MA_10)
    df["MA_50"] = df["close"].rolling(ma_slow).mean()  # Calculate slow moving average (MA_50)
    return df.dropna()  # Drop rows with NaN values (due to rolling calculation)
# Detect a bullish crossover: when MA_10 crosses above MA_50
def long_signal(df):
    return df["MA_10"].iloc[-1] > df["MA_50"].iloc[-1] and df["MA_10"].iloc[-2] <= df["MA_50"].iloc[-2]
# Detect a bearish crossunder: when MA_10 crosses below MA_50
def short_signal(df):
    return df["MA_10"].iloc[-1] < df["MA_50"].iloc[-1] and df["MA_10"].iloc[-2] >= df["MA_50"].iloc[-2]
# Place a market order (either buy or sell) with specified parameters
def place_order(symbol, order_type, lot, sl, tp, price):
    # Create a trading request for the specified order
    request = {
        "action": mt5.TRADE_ACTION_DEAL,
        "symbol": symbol,
        "volume": lot,
        "type": order_type,
        "price": price,
        "sl": sl,
        "tp": tp,
        "deviation": 10,
        "magic": 101,
        "comment": "Python script order",
        "type_time": mt5.ORDER_TIME_GTC,
        "type_filling": mt5.ORDER_FILLING_IOC
    }
    # Send the trading request
    result = mt5.order_send(request)
    if result.retcode != mt5.TRADE_RETCODE_DONE:
        print(f"Order failed: {result.retcode}, Error: {mt5.last_error()}")  # Print error if order fails
    else:
        print(f"Order placed: Ticket {result.order}, Price {result.price}, SL {sl}, TP {tp}")  # Print order details
# Check if there is an existing position of the specified type for the symbol
def existing_position(symbol, order_type):
    positions = mt5.positions_get(symbol=symbol)
    if positions:
        for position in positions:
            if position.type == order_type:  # Check if position matches the desired order type
                return True
    return False
# Main trading logic: checks for signals and places orders based on the conditions
def main(symbol):
    # Create a DataFrame with historical data and moving averages
    data = create_df(symbol, 100, 10, 50)
    if data.empty:
        print(f"No data for {symbol}")  # Print a warning if no data is available
        return
    # Check for a long signal (bullish crossover) and place a buy order if no active long position exists
    if long_signal(data) and not existing_position(symbol, mt5.ORDER_TYPE_BUY):
        print("Long signal generated, no active long positions!")
        sl = mt5.symbol_info_tick(symbol).ask * 0.99  # Set stop loss at 1% below ask price
        tp = mt5.symbol_info_tick(symbol).ask * 1.01  # Set take profit at 1% above ask price
        place_order(symbol, mt5.ORDER_TYPE_BUY, 0.1, sl, tp, mt5.symbol_info_tick(symbol).ask)
    # Check for a short signal (bearish crossunder) and place a sell order if no active short position exists
    elif short_signal(data) and not existing_position(symbol, mt5.ORDER_TYPE_SELL):
        print("Short signal generated, no active short positions!")
        sl = mt5.symbol_info_tick(symbol).bid * 1.01  # Set stop loss at 1% above bid price
        tp = mt5.symbol_info_tick(symbol).bid * 0.99  # Set take profit at 1% below bid price
        place_order(symbol, mt5.ORDER_TYPE_SELL, 0.1, sl, tp, mt5.symbol_info_tick(symbol).bid)
    else:
        print("Trading rules not satisfied!")  # Print if no trading signals are generated
if __name__ == "__main__":
    # Initialize MetaTrader 5 connection with account credentials
    initialize_mt5(login=52021XXX, server="XXX-Demo", password="G3wt9SY&4wXXXX")
    symbol = "EURUSD"  # Define the trading symbol
    # Run the trading logic in a loop for 1 hour
    timeout = time.time() + (60 * 60)  # 1-hour runtime = 60 seconds 60 times
    while time.time() <= timeout:
        main(symbol)  # Execute the main trading logic
        time.sleep(3)  # Wait 3 seconds before the next iteration
    
    mt5.shutdown()  # Cleanly shut down the MetaTrader 5 connection

Conclusion

This trading bot automates the crossover/crossunder strategy in MetaTrader 5 using Python. It simplifies trading by detecting trends, placing trades, and managing positions without manual intervention. With this framework, you can expand the bot by incorporating additional indicators, risk management rules, and more complex logic.

Previous: Trading History