Building a Telegram Trading Bot for MetaTrader 5
Build a Telegram trading bot for MetaTrader 5 in Python: /balance, /positions, /buy, /close commands plus live price alerts, wired to a hosted MT5 API.
There's a specific kind of joy in typing /buy BTCUSDm 0.10 into a Telegram
chat on your phone, at a bus stop, and watching a real order land on your
MetaTrader 5 account a half-second later. No terminal open. No VPS to babysit.
No MetaTrader running anywhere you can see. Just you, a chat box, and a bot that
does what it's told.
That's what we're building today: a small, honest, genuinely useful Telegram
bot that talks to your MT5 broker account. It'll answer /balance, list your
open /positions, place trades with /buy, close them with /close, and — the
fun part — push you a message the instant BTC crosses a price you care about.
The trick is that Telegram bots and MetaTrader 5 don't speak the same language, and MT5 has no HTTP interface of its own. We bridge them with a hosted MT5 API: your bot makes plain function calls, the hosted service holds the broker connection and does the trading.
What you'll need
Two libraries and two tokens:
pip install python-telegram-bot tickerall
- A Telegram bot token from @BotFather — message
it
/newbot, pick a name, copy the token. - A TickerAll API key (
cf_live_...) from your dashboard. - Your own Telegram user ID — message @userinfobot and it'll tell you. We'll use it to make sure only you can place trades.
If you've never placed a trade through the API before, the
first-trade walkthrough covers the
REST side in isolation — this post assumes you're comfortable with orders.place.
The bot skeleton
We open one broker session at startup and reuse it for every command. The
TickerAll REST methods are synchronous and thread-safe, and Telegram handlers
are async, so we call the client through asyncio.to_thread to keep the event
loop responsive.
import asyncio
import os
from telegram import Update
from telegram.ext import ApplicationBuilder, CommandHandler, ContextTypes
from tickerall import (
Tickerall,
TickerallServiceUnavailableError,
TickerallBrokerError,
TickerallValidationError,
)
# --- config -----------------------------------------------------------------
TELEGRAM_TOKEN = os.environ["TELEGRAM_TOKEN"]
OWNER_IDS = {123456789} # your Telegram user id(s) — the trade allowlist
client = Tickerall(api_key=os.environ["TICKERALL_API_KEY"])
# Open the broker session once, at startup. Use a DEMO account while testing.
session = client.sessions.start(
broker="mt5",
server="Exness-MT5Trial7",
account=12345678,
password=os.environ["MT5_PASSWORD"],
)
def owner_only(func):
"""Refuse trade commands from anyone not on the allowlist."""
async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE):
if update.effective_user.id not in OWNER_IDS:
await update.message.reply_text("⛔ Not authorized.")
return
return await func(update, context)
return wrapper
That owner_only decorator is small but load-bearing. Hold that thought — the
safety section below is the most important part of this whole post.
/balance — your first read
Read commands are the gentle introduction: they touch nothing, they just report.
accounts.get hands back the live account snapshot.
async def balance_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
try:
detail = await asyncio.to_thread(client.accounts.get, session.account_id)
except TickerallServiceUnavailableError:
await update.message.reply_text("⏳ Broker busy, try again in a sec.")
return
await update.message.reply_text(
f"💰 Balance: {detail.balance:.2f}\n"
f"📈 Equity: {detail.equity:.2f}"
)
TickerallServiceUnavailableError has a .transient flag — when it's True,
the failure is momentary (broker reconnecting, a hiccup) and retrying is
reasonable. For a chat bot, "try again in a sec" is a perfectly good answer.
/positions — what's open right now
The same snapshot carries your open positions. Each one has a ticket,
symbol, side, volume, and live profit, so we can format a tidy list.
async def positions_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
detail = await asyncio.to_thread(client.accounts.get, session.account_id)
if not detail.positions:
await update.message.reply_text("📭 No open positions.")
return
lines = ["📊 *Open positions*"]
for p in detail.positions:
emoji = "🟢" if p.profit >= 0 else "🔴"
lines.append(
f"{emoji} `{p.ticket}` {p.symbol} {p.side} {p.volume} "
f"→ {p.profit:+.2f}"
)
await update.message.reply_text("\n".join(lines), parse_mode="Markdown")
Notice we send p.ticket back to the user — that's the handle they'll pass to
/close in a moment.
/buy and /close — the commands that move money
Here's where a chat message becomes a real order. /buy BTCUSDm 0.10 parses to a
symbol and a volume; orders.place sends it as a market order and returns a
ticket and status.
@owner_only
async def buy_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
try:
symbol = context.args[0]
volume = float(context.args[1])
except (IndexError, ValueError):
await update.message.reply_text("Usage: /buy SYMBOL VOLUME (e.g. /buy BTCUSDm 0.10)")
return
try:
order = await asyncio.to_thread(
client.orders.place,
session.account_id,
type="market",
symbol=symbol,
side="BUY",
volume=volume,
)
except TickerallValidationError as e:
await update.message.reply_text(f"⚠️ Rejected: {e}")
return
except TickerallBrokerError as e:
await update.message.reply_text(f"🏦 Broker error: {e}")
return
await update.message.reply_text(
f"✅ BUY {volume} {symbol}\nticket `{order.ticket}` — {order.status}",
parse_mode="Markdown",
)
@owner_only
async def close_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
try:
ticket = int(context.args[0])
except (IndexError, ValueError):
await update.message.reply_text("Usage: /close TICKET (e.g. /close 5417822)")
return
await asyncio.to_thread(client.positions.close, session.account_id, ticket=ticket)
await update.message.reply_text(f"🔒 Closed position `{ticket}`", parse_mode="Markdown")
Both trade commands wear the @owner_only decorator. TickerallValidationError
catches the bad-input cases early (unknown symbol, a volume below the broker's
minimum), and TickerallBrokerError catches a rejection from the broker itself
(market closed, not enough margin) — so a fat-fingered command gets a clear reply
instead of a stack trace.
⚠️ Read this before you wire up
/buy. A chat command places a real order with real money. Two non-negotiable rules: (1) gate every trade command behind an allowlist of your own Telegram user IDs — thatOWNER_IDSset and the@owner_onlydecorator — because bots get added to group chats, forwarded, and probed by strangers. (2) Point the session at a demo account until you've watched it behave for a while. The demo server above (Exness-MT5Trial7) trades on paper money. Prove it out there first; a typo in a volume argument is a lot cheaper on a demo.
Live price alerts from the stream
Reads and trades are request/response. Price alerts are the opposite shape — you want the bot to message you, unprompted, the moment the market moves. That's the WebSocket stream's job. It runs on its own background thread and reconnects/re-subscribes on its own, so you connect once and forget about it.
The one wrinkle: the stream callback runs on a plain thread, not the asyncio
loop, and Telegram's send_message is a coroutine. We bridge them with
run_coroutine_threadsafe.
ALERT_LEVEL = 65000.0
ALERT_CHAT_ID = 123456789 # where alerts get delivered
_last_bid = None # remember the previous price to detect a *cross*
def start_price_alerts(application):
loop = application.loop
stream = client.stream.connect()
def on_tick(e):
global _last_bid
prev, _last_bid = _last_bid, e.bid
if prev is None:
return
# fire only when we cross the level, not on every tick above it
crossed_up = prev < ALERT_LEVEL <= e.bid
crossed_down = prev > ALERT_LEVEL >= e.bid
if crossed_up or crossed_down:
arrow = "🚀 up through" if crossed_up else "📉 down through"
asyncio.run_coroutine_threadsafe(
application.bot.send_message(
ALERT_CHAT_ID,
f"{arrow} {ALERT_LEVEL:.0f} — {e.symbol} now {e.bid}",
),
loop,
)
stream.on("tick", on_tick)
stream.subscribe_ticks(session.account_id, ["BTCUSDm"])
stream.on("tick", ...) registers a callback — it's a plain method, not a
decorator. The prev < LEVEL <= bid check is what turns a firehose of ticks into
a single alert on the crossing, instead of a spam storm every time price sits
above the line. If you'd rather poll the latest price inside another command,
stream.latest_tick("BTCUSDm") gives you the last tick in O(1) (or None before
the first one arrives). The tick-streaming deep dive
goes further into the stream API if you want to build richer alerts.
Wiring it together
Register the handlers, kick off the alert thread, and run:
def main():
app = ApplicationBuilder().token(TELEGRAM_TOKEN).build()
app.add_handler(CommandHandler("balance", balance_cmd))
app.add_handler(CommandHandler("positions", positions_cmd))
app.add_handler(CommandHandler("buy", buy_cmd))
app.add_handler(CommandHandler("close", close_cmd))
start_price_alerts(app)
app.run_polling()
if __name__ == "__main__":
main()
That's the whole bot — a couple hundred lines, no infrastructure, and it runs anywhere Python runs: your laptop, a $5 box, a Raspberry Pi in a drawer.
Wrap-up
You've now got a MetaTrader 5 bot in your pocket: read your balance and
positions, open and close trades with a chat command, and get pinged when the
market crosses a level you care about — all gated to your own account and pointed
at a demo while you shake it out. The satisfying part is how little there is to
it, because the hosted API carries the broker connection so your code only ever
deals in place, close, and on("tick", ...).
If you'd rather not build one at all, TickerAll ships a built-in Telegram and Discord bot you can point at your accounts with zero code — but honestly, rolling your own is the more fun afternoon.
Full method reference and the streaming API are in the TickerAll docs.