Build your first MT5 trading bot in Python

A small Python trading bot for MT5 over plain REST: health checks, candles, a toy moving-average signal, orders that can't double, and correct handling of unknown outcomes.

A code window beside a candlestick chart where two moving averages cross, with an order marked as applied.

This post builds a complete, small trading bot for MetaTrader 5 (MT5) in Python. It reads the account’s health, fetches candles, computes a signal, places and closes orders, and copes with the case most tutorials skip: an order whose result nobody can confirm.

The bot uses plain HTTP with the requests library against a hosted MT5 terminal, so it runs on a Mac, Linux, Windows or a small server. There is nothing to install on Windows and no terminal to keep open on your own machine.

The strategy is a toy: a moving-average cross on one symbol. It was chosen because it is short and easy to read. It is not a recommendation, it is not expected to make money, and you should not trade it with real money. The point is the plumbing around it, which you will reuse for any strategy you write.

What the bot does

Once a minute, the bot:

  1. Checks the account is connected to the broker and that prices are moving. If not, it waits.
  2. Fetches recent 1-hour candles for EURUSD and keeps only the closed ones. A candle is one bar on the chart: the open, high, low and close price for that hour.
  3. When a new candle has closed, compares a fast moving average (the average close of the last 10 candles) with a slow one (the last 30).
  4. If the fast average has just crossed above the slow one and the bot holds nothing, it buys 0.01 lots. A lot is MetaTrader’s unit of size; at most brokers 0.01 lots is 1,000 units of the base currency.
  5. If the fast average has just crossed below, it closes the position it opened.

Every order carries an idempotency key built from the candle’s time. If the bot retries, or crashes and restarts, the same decision produces the same key, and the server returns the first result instead of placing a second order.

Before you start

You need Python 3.9 or later from python.org and the requests library:

python3 -m pip install requests

You also need an API key and an account id. The free sandbox gives you both in one call, with a simulated MT5 account that answers the real API, so you can build the whole bot without a broker login:

curl -s -X POST "https://api.tickwire.io/v1/sandbox/signup" \
  -H "content-type: application/json" -d '{"platform": "mt5"}'

The answer contains apiKey (it starts with twsk_) and account.id. Put them in environment variables so they never end up in your code:

export TW_KEY=twsk_...
export TW_ACCOUNT=<ACCOUNT_ID>

When the bot works, a live key (tw_live_...) and a connected broker account replace these two values. Nothing else changes.

The three calls the bot reads

All reads go to /users/current/accounts/<ACCOUNT_ID>/... with the key in an auth-token header.

Health. GET .../health answers whether the account is really working. The bot trades only when status is healthy, trading_enabled is true and prices_live is true. prices_live is false when no price has moved for a while, usually because the market is closed, and null when it can’t tell yet. Both mean “do not trade”. See health.

Candles. GET .../historical-market-data/symbols/EURUSD/timeframes/1h/candles?limit=40 returns up to 40 candles, oldest first, each with time, open, high, low and close. The newest one may still be forming, so the bot drops it and decides only on closed candles. Deciding on an unfinished candle is a classic source of signals that look great in testing and vanish live.

Positions. GET .../positions lists open positions. Each has an id, symbol, type (POSITION_TYPE_BUY or POSITION_TYPE_SELL), volume and the clientId you set when you opened it. The bot recognises its own positions by a clientId starting with mac, so it ignores trades you place by hand.

Orders that cannot double

Orders go to POST .../trade. Three headers matter:

  • Idempotency-Key: the same key with the same body returns the stored result and sends nothing to the broker again. The bot builds it from the account id and the candle time.
  • X-Actor and X-Reason: who sent the order and why. Both are written to the audit log.

The answer is HTTP 200 even when the broker refuses, so the bot reads the outcome field rather than the status code:

outcomeMeaningWhat the bot does
appliedThe terminal confirmed itCarries on
partialPart of the volume filledCarries on, holds a smaller position
no_changeNothing needed doing, such as closing a closed positionCarries on
rejectedThe broker refused it; nothing happenedLogs it and waits for the next signal
unknownIt may have executedRetries with the same key, then checks positions

The last row is the one that matters. unknown means the connection dropped after the order left, so it may have filled. Sending it again with a new key is how traders end up with two positions. The bot retries with the same key a few times, which makes the server re-check before doing anything. If the answer is still unknown, it looks for its clientId in the open positions. If it finds the position, the order went through. If not, the bot stops and asks a human to look, because “not found yet” is not proof that nothing happened. See idempotency for the full rules.

A few HTTP answers need the same care. A timeout or connection error on your side, 409 while the first attempt is still running, 429 when you are over a rate limit, and a 503 or other server error all carry the same instruction: wait (for Retry-After when it is sent) and repeat with the same key.

The full script

import os
import sys
import time

import requests

API = "https://api.tickwire.io"
KEY = os.environ["TW_KEY"]            # twsk_... in the sandbox, tw_live_... for real
ACC = os.environ["TW_ACCOUNT"]        # <ACCOUNT_ID>
BASE = f"{API}/users/current/accounts/{ACC}"
AUTH = {"auth-token": KEY}

SYMBOL, TIMEFRAME, VOLUME = "EURUSD", "1h", 0.01
FAST, SLOW = 10, 30                   # a toy strategy, not a recommendation
TAG = "mac"                           # clientId prefix that marks our positions


def get(path, **params):
    r = requests.get(BASE + path, headers=AUTH, params=params, timeout=15)
    r.raise_for_status()
    return r.json()


def ready():
    h = get("/health")
    return h["status"] == "healthy" and h["trading_enabled"] and h["prices_live"] is True


def closed_candles():
    bars = get(f"/historical-market-data/symbols/{SYMBOL}/timeframes/{TIMEFRAME}/candles",
               limit=SLOW + 10)
    return bars[:-1]                  # the newest candle may still be forming


def sma(values, n):
    return sum(values[-n:]) / n


def signal(bars):
    closes = [b["close"] for b in bars]
    if len(closes) < SLOW + 1:
        return None
    before = sma(closes[:-1], FAST) - sma(closes[:-1], SLOW)
    now = sma(closes, FAST) - sma(closes, SLOW)
    if before <= 0 < now:
        return "buy"
    if before >= 0 > now:
        return "exit"
    return None


def my_positions():
    return [p for p in get("/positions")
            if p["symbol"] == SYMBOL and p.get("clientId", "").startswith(TAG)]


def trade(body, key, reason, attempts=5):
    """Send one order. Every retry reuses the same Idempotency-Key."""
    headers = {**AUTH, "Idempotency-Key": key, "X-Actor": "mac-bot", "X-Reason": reason}
    result = {"outcome": "unknown"}
    for _ in range(attempts):
        try:
            r = requests.post(BASE + "/trade", json=body, headers=headers, timeout=30)
        except requests.RequestException:
            time.sleep(5)             # no answer at all: it may have arrived
            continue
        wait = r.headers.get("Retry-After")
        if r.status_code >= 500 or (r.status_code in (409, 429) and wait):
            time.sleep(int(wait or 5))    # still running, rate limited, or a server error
            continue
        r.raise_for_status()          # any other 4xx: fix the request, nothing was sent
        result = r.json()
        if result.get("outcome") != "unknown":
            return result
        time.sleep(5)                 # unknown: ask again with the same key
    return result


def stop(message):
    print("STOPPING:", message)
    sys.exit(1)


def act(sig, bar_time):
    mine = my_positions()
    stamp = "".join(c for c in bar_time if c.isdigit())[:12]     # e.g. 202610201300

    if sig == "buy" and not mine:
        cid = f"{TAG}{stamp}"
        res = trade({"actionType": "ORDER_TYPE_BUY", "symbol": SYMBOL,
                     "volume": VOLUME, "clientId": cid},
                    key=f"{ACC}-{cid}-open",
                    reason=f"fast average crossed above slow on the {bar_time} candle")
        print("buy:", res.get("outcome"), res.get("stringCode"))
        if res.get("outcome") == "unknown":
            if any(p.get("clientId") == cid for p in my_positions()):
                print("buy: found the position, it was filled")
            else:
                stop(f"buy {cid} has an unknown outcome; check the account before restarting")

    elif sig == "exit":
        for p in mine:
            res = trade({"actionType": "POSITION_CLOSE_ID", "positionId": p["id"]},
                        key=f"{ACC}-{p['id']}-close",
                        reason=f"fast average crossed below slow on the {bar_time} candle")
            print("close:", p["id"], res.get("outcome"), res.get("stringCode"))
            if res.get("outcome") == "unknown":
                if any(q["id"] == p["id"] for q in my_positions()):
                    stop(f"close of {p['id']} has an unknown outcome; check the account")
                print("close: position is gone, it was closed")


def main():
    last_bar = None
    while True:
        try:
            if not ready():
                print("not ready: disconnected, no trading, or no live prices")
            else:
                bars = closed_candles()
                if bars and bars[-1]["time"] != last_bar:
                    last_bar = bars[-1]["time"]
                    sig = signal(bars)
                    print(last_bar, "signal:", sig)
                    if sig:
                        act(sig, last_bar)
        except requests.RequestException as e:
            print("read failed, will try again:", e)
        time.sleep(60)


if __name__ == "__main__":
    main()

Why it is built this way

  • Keys come from the decision, not from a random number. The key for a buy is the account plus the candle time. If the bot crashes after sending and restarts, it sees the same candle, builds the same key, and gets the stored answer back. A random key per attempt would place a second order.
  • Different body, different key. Reusing a key for a different order is 409 idempotency_conflict. That is a bug, not a reason to retry, so opens and closes use different keys.
  • It never trades on a frozen price. If health is anything other than healthy with live prices, the bot does nothing and says why.
  • It stops rather than guesses. An unresolved unknown is the one moment a bot should hand over to a person. Stopping costs a missed trade; guessing can cost a doubled position.
  • Times are labelled. Candle time is UTC. Broker server time is a separate field and is often not UTC; see time zones before you add rules like “no trading on Friday evening”.

Test the failure paths

The happy path proves little. The sandbox has magic logins that each simulate one problem, so you can watch the bot handle it:

  • 1032: the reply to new orders is lost and no fill appears. After about 3 seconds the outcome is unknown. The bot should retry with the same key, fail to find the position and stop.
  • 1031: the reply is lost but the order was filled. The outcome comes back applied, with nothing to retry.
  • 1019: the broker rejects the order for lack of money. The outcome is rejected, and the bot waits for the next signal.
  • 1040: the broker link drops for 30 seconds every 90. The bot should print “not ready” and send nothing while it is down.
  • 1050: quotes freeze. After 30 seconds prices_live is false and the bot pauses.

Create an account with one of these logins, point TW_ACCOUNT at it, and read the output. See the sandbox page for the exact request.

Checklist before real money

  • The bot ran for weeks on live prices without real money, and every order matched what you expected.
  • You practised on a demo account before you risk money, at the smallest size.
  • A daily loss limit and a maximum position size are in your code. The API does not set them.
  • You have read every unknown, rejected and “not ready” line in the log.
  • You know how to stop the bot at once.

Where to go next

The same script works for MT4 accounts: change only the account id. The trading bot guide shows how to replace polling with the WebSocket stream, and MT5 from Python covers the API from Python more broadly. For the reasoning behind the key rules, read a retry should never place a second trade. Tickwire gives no investment advice and does not judge whether a strategy is good.

Trading carries risk, and you can lose money. This post explains software and how it behaves; it is not investment advice.

Try it on your own account

7-day free trial. See pricing or read the quickstart.