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.
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:
- Checks the account is connected to the broker and that prices are moving. If not, it waits.
- 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.
- 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).
- 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.
- 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-ActorandX-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:
outcome | Meaning | What the bot does |
|---|---|---|
applied | The terminal confirmed it | Carries on |
partial | Part of the volume filled | Carries on, holds a smaller position |
no_change | Nothing needed doing, such as closing a closed position | Carries on |
rejected | The broker refused it; nothing happened | Logs it and waits for the next signal |
unknown | It may have executed | Retries 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
healthywith live prices, the bot does nothing and says why. - It stops rather than guesses. An unresolved
unknownis 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
timeis 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 isunknown. 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 backapplied, with nothing to retry.1019: the broker rejects the order for lack of money. The outcome isrejected, 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 secondsprices_liveisfalseand 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,rejectedand “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.