TICKERALL!
← All posts
August 23, 20266 min read

Building a Discord Trading Bot for MetaTrader 5

Build a Discord trading bot for MetaTrader 5 in Python: /balance, /positions, /buy, /close slash commands plus price alerts, wired to a hosted MT5 API.

MetaTrader 5DiscordPythonTrading Bot

Your trading Discord already has a channel where everyone posts charts, argues about the Fed, and screenshots their wins (never their losses). What it doesn't have — yet — is a bot that lets you type /buy BTCUSDm 0.10 in that same channel and watch a real order land on your MetaTrader 5 account a half-second later. No terminal open. No VPS to babysit. Just a slash command and a bot that does what it's told.

That's what we're building: a small, honest, genuinely useful Discord bot wired 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 — post to a channel the instant BTC crosses a price you care about.

The catch is that Discord 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.

Discord /buy BTCUSDm 0.10 Your bot discord.py Hosted API TickerAll Broker
A slash 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 "discord.py>=2.0" tickerall
  • A Discord bot token from the Developer Portal — create an application, add a bot, copy the token. Invite it to your server with the applications.commands scope so slash commands show up.
  • A TickerAll API key (cf_live_...) from your dashboard.
  • Your own Discord user ID — turn on Developer Mode, right-click your name, "Copy User ID". 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.

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 discord.py handlers are async, so we call the client through asyncio.to_thread to keep the event loop responsive. Slash commands live on an app_commands.CommandTree.

import asyncio
import os

import discord
from discord import app_commands

from tickerall import (
    Tickerall,
    TickerallServiceUnavailableError,
    TickerallBrokerError,
    TickerallValidationError,
)

# --- config -----------------------------------------------------------------
DISCORD_TOKEN = os.environ["DISCORD_TOKEN"]
GUILD_ID = 111111111111111111        # your server — commands register here instantly
OWNER_IDS = {123456789012345678}     # your Discord 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"],
)

intents = discord.Intents.default()
bot = discord.Client(intents=intents)
tree = app_commands.CommandTree(bot)


@bot.event
async def on_ready():
    # Sync slash commands to your guild so they appear immediately.
    await tree.sync(guild=discord.Object(id=GUILD_ID))
    print(f"Logged in as {bot.user}")

We'll also want a tiny allowlist check for the trade commands. discord.py has a neat way to express this — an app_commands.check that raises when the caller isn't on the list:

def owner_only():
    async def predicate(interaction: discord.Interaction) -> bool:
        if interaction.user.id not in OWNER_IDS:
            raise app_commands.CheckFailure("Not authorized.")
        return True
    return app_commands.check(predicate)

That check is small but load-bearing. Hold that thought — the safety section below is the most important part of this whole post.

/balance

Read commands are the gentle introduction: they touch nothing, they just report. accounts.get hands back the live account snapshot. Because a broker round-trip can take a moment, we defer() the interaction first so Discord doesn't time out the "thinking..." state.

@tree.command(name="balance", description="Show account balance and equity",
              guild=discord.Object(id=GUILD_ID))
async def balance(interaction: discord.Interaction):
    await interaction.response.defer()
    try:
        detail = await asyncio.to_thread(client.accounts.get, session.account_id)
    except TickerallServiceUnavailableError:
        await interaction.followup.send("⏳ Broker busy, try again in a sec.")
        return

    await interaction.followup.send(
        f"💰 **Balance:** {detail.balance:.2f}\n"
        f"📈 **Equity:**  {detail.equity:.2f}"
    )

TickerallServiceUnavailableError carries 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

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.

@tree.command(name="positions", description="List open positions",
              guild=discord.Object(id=GUILD_ID))
async def positions(interaction: discord.Interaction):
    await interaction.response.defer()
    detail = await asyncio.to_thread(client.accounts.get, session.account_id)

    if not detail.positions:
        await interaction.followup.send("📭 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 interaction.followup.send("\n".join(lines))

Notice we send p.ticket back to the user — that's the handle they'll pass to /close in a moment.

/buy and /close

Here's where a slash command becomes a real order. Slash commands take typed arguments, so symbol and volume arrive already parsed — no string wrangling. orders.place sends a market order and returns a ticket and status.

@tree.command(name="buy", description="Place a market BUY order",
              guild=discord.Object(id=GUILD_ID))
@app_commands.describe(symbol="e.g. BTCUSDm", volume="lots, e.g. 0.10")
@owner_only()
async def buy(interaction: discord.Interaction, symbol: str, volume: float):
    await interaction.response.defer()
    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 interaction.followup.send(f"⚠️ Rejected: {e}")
        return
    except TickerallBrokerError as e:
        await interaction.followup.send(f"🏦 Broker error: {e}")
        return

    await interaction.followup.send(
        f"✅ **BUY** {volume} {symbol}\nticket `{order.ticket}` — {order.status}"
    )


@tree.command(name="close", description="Close a position by ticket",
              guild=discord.Object(id=GUILD_ID))
@app_commands.describe(ticket="the position ticket from /positions")
@owner_only()
async def close(interaction: discord.Interaction, ticket: int):
    await interaction.response.defer()
    await asyncio.to_thread(client.positions.close, session.account_id, ticket=ticket)
    await interaction.followup.send(f"🔒 Closed position `{ticket}`")

Both trade commands wear the @owner_only() check. 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.

One more piece: when the allowlist check fails, discord.py raises CheckFailure. Catch it once, globally, and reply politely instead of letting it bubble into your logs:

@tree.error
async def on_app_command_error(interaction: discord.Interaction, error):
    if isinstance(error, app_commands.CheckFailure):
        msg = "⛔ Not authorized to trade with this bot."
        if interaction.response.is_done():
            await interaction.followup.send(msg, ephemeral=True)
        else:
            await interaction.response.send_message(msg, ephemeral=True)
    else:
        raise error

⚠️ Read this before you wire up /buy. A slash command places a real order with real money. Two non-negotiable rules: (1) gate every trade command behind an allowlist of your own Discord user IDs — that OWNER_IDS set and the @owner_only() check — and, ideally, pin the bot to a specific guild and even a private channel, because bots get invited to servers, DM'd, 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.

Price alerts

Reads and trades are request/response. Price alerts are the opposite shape — you want the bot to post to a channel 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 posting to a Discord channel is a coroutine. We bridge them with run_coroutine_threadsafe.

ALERT_LEVEL = 65000.0
ALERT_CHANNEL_ID = 222222222222222222   # where alerts get posted
_last_bid = None                        # remember the previous price to detect a *cross*


def start_price_alerts():
    loop = asyncio.get_event_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"
            channel = bot.get_channel(ALERT_CHANNEL_ID)
            asyncio.run_coroutine_threadsafe(
                channel.send(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.

Call start_price_alerts() from on_ready (after the command sync), then start the bot:

bot.run(DISCORD_TOKEN)

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.

Don't want to build one?

TickerAll ships a built-in Discord bot you can link to your accounts straight from the dashboard — zero code, point it at a pool, done — if you'd rather not run your own. (Honestly, though, rolling your own is the more fun afternoon.)

The no-SDK route: a webhook in any language

You don't even need Python or the SDK. Every account has a webhook URL, and your Discord bot — in any language — can just POST a small JSON command to it:

{ "action": "buy", "symbol": "BTCUSD", "volume": 0.01 }

The fields are action (buy/sell/close/modify), symbol, volume, an optional type (limit/stop) with a price, plus optional sl, tp, and a ticket for closes and modifies. So a Discord bot written in Go, Rust, or a bare fetch in Node can trade your account with a single HTTP call. The TradingView alerts guide documents the webhook payload in full — the format is identical wherever the POST comes from.

Wrap-up

You've now got a MetaTrader 5 bot living in your trading Discord: read your balance and positions, open and close trades with a slash command, and get a channel ping 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", ...).

Prefer Telegram? The same bot, chat-side, is in the Telegram build. Full method reference and the streaming API are in the TickerAll docs.

Was this useful?

Comments

Leave a comment

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