TICKERALL!
← All posts
August 23, 20265 min read

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.

MetaTrader 5TelegramPythonTrading Bot

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.

Telegram chat /buy BTCUSDm Your bot python-telegram-bot Hosted API TickerAll Broker
A chat command flows through your bot into the hosted API and out to your broker — nothing local to run.

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 — that OWNER_IDS set and the @owner_only decorator — 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.

Was this useful?

Comments

Leave a comment

Comments are public.
Building a Telegram Trading Bot for MetaTrader 5 · Ticker All!