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.
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.
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.commandsscope 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 — thatOWNER_IDSset 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.