Tutorial
Build a stock and crypto price alert bot for Discord and Telegram
Most alert bots poll a price every few seconds and spam the channel at the open. This one listens to a stream, knows when the market is shut, and posts each alert once, to Discord, Telegram or both.
On this page
Key takeaways
- One WebSocket connection carries every trade the bot needs; REST is used only for previous closes and market status, about 30 requests a day.
- Level alerts fire on a crossing (previous trade below the level, this one at or above it), so a bot started above the level stays quiet.
- Stock rules check `status == "open"` from the market status endpoint, because `is_open` is also true during pre-market.
- Replayed snapshot frames seed the last price but never trigger an alert, and every rule has a cooldown.
- Discord webhooks and Telegram bot tokens are secrets that appear in request URLs, so the bot logs only status codes on failure.
A Discord stock bot for price alerts needs three parts: a price source, a rule engine and a way to post messages. The cleanest build uses one WebSocket subscription for trades, simple rules ("BTCUSD crosses 90,000", "US:KO moves 2% from yesterday's close") and a Discord webhook or Telegram bot as output. Because a crypto price API and a stock feed share one socket and message shape, one engine covers both.
The whole bot is one Python file of about 220 lines, shown in four parts below. It runs in dry-run mode, printing alerts to the terminal, until you give it a webhook URL or a bot token.
Stream, don't poll
The tutorials that rank for this topic poll a REST endpoint on a timer. That is fine for one symbol checked every few minutes, and wasteful for anything faster. Five symbols polled every ten seconds is 43,200 requests a day, and the bot still misses a spike that reverses between two polls. A stream delivers every trade as it happens, and REST is left for the few things that do not change tick by tick. The WebSocket vs REST comparison goes through the trade-off in general.
| Feature | Polling REST every 10 s | One WebSocket stream |
|---|---|---|
| Requests for 5 symbols, per day | 43,200 | About 30 (closes and market status) |
| Sees a spike that reverses in 5 seconds | ||
| Works on the free tier | yes, for a few slow symbols | no, WebSocket is on paid plans |
| Delay between trade and alert | Up to one polling interval | Network and processing time only |
| Moving parts | A timer | A reconnect loop |
What the Discord stock bot does, in order
- GET /markets/status?asset=stocks&market=USregular session open?Bot to TickerLayer
- GET /{asset}/snapshot/{symbol}prev_close for move rulesBot to TickerLayer
- WebSocket connect, wait for readyBot to TickerLayer
- subscribe crypto.trades, stocks.tradesBot to TickerLayer
- trade framesTickerLayer to Bot
- rule check: crossing, % move, cooldown
- POST webhook / sendMessageBot to Discord / Telegram
| Rule | Fires when | Example |
|---|---|---|
above | Previous trade was below the level and this trade is at or above it | Rule("BTCUSD", "above", 90_000) |
below | Previous trade was above the level and this trade is at or below it | Rule("US:KO", "below", 87.50) |
move | Change from the previous close reaches the threshold, up or down | Rule("US:KO", "move", 2.0) for ±2% |
| All rules | Not within cooldown_s of the last alert (default 30 minutes) | Stops a price hovering at a level from posting every tick |
Part 1: rules and state
Rules are plain data. A crossing needs the previous price, so the first trade after start-up only records a price: a bot restarted while BTCUSD is already above 90,000 will not announce it again. Move rules compare against the snapshot's prev_close, which the market-hours task refreshes once per UTC day and again at each US open.
"""Stock and crypto price alerts for Discord and Telegram from one WebSocket."""
import asyncio
import json
import logging
import os
import random
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from urllib.parse import quote
import requests
import websockets # pip install websockets requests
API = "https://api.tickerlayer.com"
KEY = os.environ["TICKERLAYER_API_KEY"]
STREAM = "wss://stream.tickerlayer.com/?apiKey=" + quote(KEY, safe="")
HEADERS = {"x-api-key": KEY}
log = logging.getLogger("alerts")
@dataclass
class Rule:
symbol: str
kind: str # "above", "below" or "move" (percent versus previous close)
value: float
cooldown_s: int = 1800
last_fired: float = 0.0
def check(self, prev, price, ref):
if time.time() - self.last_fired < self.cooldown_s:
return None
if self.kind == "above" and prev is not None and prev < self.value <= price:
text = f"{self.symbol} crossed above {self.value:,.2f} (last {price:,.2f})"
elif self.kind == "below" and prev is not None and prev > self.value >= price:
text = f"{self.symbol} crossed below {self.value:,.2f} (last {price:,.2f})"
elif self.kind == "move" and ref:
pct = (price - ref) / ref * 100
if abs(pct) < self.value:
return None
text = f"{self.symbol} {pct:+.2f}% vs previous close {ref:,.2f} (last {price:,.2f})"
else:
return None
self.last_fired = time.time()
return text
RULES = [
Rule("BTCUSD", "above", 90_000),
Rule("BTCUSD", "move", 1.5),
Rule("ETHUSD", "move", 3.0),
Rule("US:KO", "below", 87.50),
Rule("US:KO", "move", 2.0),
]
last_price = {} # symbol -> last trade price seen
prev_close = {} # symbol -> reference price for "move" rules
us_open = False # True only during the regular US sessionPart 2: market hours without a timer
A stock alert at 04:05 New York time on a pre-market print is usually noise, and on a holiday there is nothing to watch. The market status endpoint answers both questions. Two details matter: is_open is true during pre-market and after-hours, so the bot checks status == "open" for the regular session; and the response carries next_open and next_close, so the bot sleeps until the next boundary instead of asking every minute.
US market status during pre-market
{
"asset": "stocks",
"market": "US",
"status": "pre_market",1
"is_open": true,2
"phase": "pre_market",
"timezone": "America/New_York",
"next_open": "2026-09-28T13:30:00.000Z",3
"next_close": "2026-09-28T20:00:00.000Z"4
}
statusopenonly in the regular session. Alsopre_market,post_market,closed.is_openTrue whenever any session applies, pre-market included. Not the flag you want for alerts.next_openUTC instant the regular session next opens. The bot wakes just after it.next_closeUTC instant the current or next regular session ends.
def asset_of(symbol):
return "stocks" if ":" in symbol else "crypto"
def load_prev_closes():
for symbol in sorted({r.symbol for r in RULES}):
resp = requests.get(f"{API}/{asset_of(symbol)}/snapshot/{symbol}", headers=HEADERS, timeout=10)
resp.raise_for_status()
prev_close[symbol] = float(resp.json()["prev_close"])
log.info("reference closes: %s", prev_close)
def us_status():
resp = requests.get(
f"{API}/markets/status", params={"asset": "stocks", "market": "US"}, headers=HEADERS, timeout=10
)
resp.raise_for_status()
return resp.json()
def seconds_to_next_boundary(status):
now = time.time()
waits = [3600.0] # re-check at least hourly
for key in ("next_open", "next_close"):
if status.get(key):
at = datetime.fromisoformat(status[key].replace("Z", "+00:00")).timestamp()
if at > now:
waits.append(at - now + 2)
return min(waits)
async def watch_market_hours():
"""One status call per session change, not one per minute."""
global us_open
loaded_for = None
while True:
try:
status = await asyncio.to_thread(us_status)
was_open, us_open = us_open, status.get("status") == "open"
today = datetime.now(timezone.utc).date()
if (us_open and not was_open) or loaded_for != today:
await asyncio.to_thread(load_prev_closes)
loaded_for = today
wait = seconds_to_next_boundary(status)
log.info("US stocks %s; next check in %.0f min", status.get("status"), wait / 60)
except requests.RequestException as exc:
log.warning("market status failed: %s", type(exc).__name__)
wait = 60
await asyncio.sleep(wait)Holidays and early closes come for free, since the endpoint already knows the calendar. The exchange calendar API tutorial covers the other 19 regional stock calendars and the sessions endpoint.
Part 3: the stream and the rule check
The reader loop does as little as possible: parse, update the last price, test the rules, and drop any alert into a queue. Trade prices arrive as strings on the crypto and stock channels, so they are converted with float(). Frames marked "snapshot": true are replayed last values sent right after a subscribe; they seed state and never alert. The reconnect loop uses jittered exponential backoff and gives up only on 401 or 403, which a retry cannot fix.
def on_trade(msg, outbox):
symbol, price = msg["symbol"], float(msg["price"]) # numbers arrive as strings
prev = last_price.get(symbol)
last_price[symbol] = price
if msg.get("snapshot"):
return # a replayed last value seeds state; never alert on it
if msg.get("asset") == "stocks" and not us_open:
return # pre-market, after-hours, weekends and holidays stay quiet
for rule in RULES:
if rule.symbol == symbol:
text = rule.check(prev, price, prev_close.get(symbol))
if text:
outbox.put_nowait(text)
async def stream(outbox):
subs = {}
for rule in RULES:
subs.setdefault(f"{asset_of(rule.symbol)}.trades", set()).add(rule.symbol)
backoff = 1.0
while True:
try:
async with websockets.connect(STREAM, compression=None, open_timeout=10) as ws:
ready = json.loads(await ws.recv())
if ready.get("event") != "ready":
raise RuntimeError(f"expected ready, got {ready}")
for channel, symbols in subs.items():
await ws.send(json.dumps({"action": "subscribe", "channels": [channel], "symbols": sorted(symbols)}))
backoff = 1.0
async for raw in ws:
msg = json.loads(raw)
if msg.get("type") == "trade":
on_trade(msg, outbox)
elif msg.get("type") == "error":
log.error("stream error: %s %s", msg.get("code"), msg.get("message"))
except websockets.InvalidStatus as exc:
if exc.response.status_code in (401, 403):
raise # wrong key or no WebSocket entitlement: retrying will not help
log.warning("upgrade refused with HTTP %s", exc.response.status_code)
except (websockets.ConnectionClosed, OSError, asyncio.TimeoutError) as exc:
log.warning("disconnected: %s", type(exc).__name__)
await asyncio.sleep(backoff + random.uniform(0, backoff / 2))
backoff = min(backoff * 2, 30.0)Part 4: Discord webhook and Telegram Bot API adapters
Each output is a class with one send(text) method, so adding Slack or email later is another ten lines. Discord takes a JSON content field of up to 2,000 characters on a webhook URL; Telegram takes chat_id and text on sendMessage. Both answer 429 with a retry delay when you post too fast, and both adapters honour it.
class Discord:
def __init__(self, webhook_url):
self.url = webhook_url
def send(self, text):
for _ in range(3):
resp = requests.post(self.url, json={"content": text[:2000]}, timeout=10)
if resp.status_code == 429:
time.sleep(float(resp.json().get("retry_after", 1)))
continue
resp.raise_for_status()
return
class Telegram:
def __init__(self, token, chat_id):
self.url = f"https://api.telegram.org/bot{token}/sendMessage"
self.chat_id = chat_id
def send(self, text):
for _ in range(3):
resp = requests.post(self.url, json={"chat_id": self.chat_id, "text": text}, timeout=10)
if resp.status_code == 429:
time.sleep(float(resp.json().get("parameters", {}).get("retry_after", 1)))
continue
resp.raise_for_status()
return
class Console:
def send(self, text):
print("ALERT", text, flush=True)
def sinks_from_env():
sinks = []
if os.environ.get("DISCORD_WEBHOOK_URL"):
sinks.append(Discord(os.environ["DISCORD_WEBHOOK_URL"]))
if os.environ.get("TELEGRAM_BOT_TOKEN") and os.environ.get("TELEGRAM_CHAT_ID"):
sinks.append(Telegram(os.environ["TELEGRAM_BOT_TOKEN"], os.environ["TELEGRAM_CHAT_ID"]))
return sinks or [Console()] # no chat configured: dry run to the terminal
async def deliver(outbox, sinks):
while True:
text = await outbox.get()
for sink in sinks:
try:
await asyncio.to_thread(sink.send, text)
except requests.RequestException as exc:
# Log the status only: the exception text contains the webhook URL or bot token.
status = getattr(exc.response, "status_code", None)
log.error("%s delivery failed (HTTP %s)", type(sink).__name__, status)
async def main():
logging.Formatter.converter = time.gmtime # log in UTC
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
outbox = asyncio.Queue()
await asyncio.gather(watch_market_hours(), stream(outbox), deliver(outbox, sinks_from_env()))
if __name__ == "__main__":
asyncio.run(main())Connect Discord and Telegram, then run it
- Discord webhookIn your server: Server Settings > Integrations > Webhooks > New Webhook, pick the channel, then Copy Webhook URL. Export it as
DISCORD_WEBHOOK_URL. - Telegram botMessage @BotFather, send
/newbot, and keep the token asTELEGRAM_BOT_TOKEN. Send your bot a message, openhttps://api.telegram.org/bot<token>/getUpdates, and copymessage.chat.idintoTELEGRAM_CHAT_ID. - API keyExport
TICKERLAYER_API_KEY. The account needs WebSocket access for the feeds you watch: crypto for BTCUSD and ETHUSD, US stocks for US:KO. - Run
pip install websockets requests, thenpython alert_bot.py. With no chat variables set it prints alerts to the terminal.
2026-09-28 10:56:11,703 INFO reference closes: {'BTCUSD': 84472.0, 'ETHUSD': 2688.65, 'US:KO': 87.81}
2026-09-28 10:56:11,703 INFO US stocks pre_market; next check in 60 min
ALERT BTCUSD -1.76% vs previous close 84,472.00 (last 82,985.21)In that run BTCUSD was down 1.76% on the day, past its 1.5% move rule, so the first live trade fired an alert. ETHUSD was down about 1.2%, inside its 3% rule, and US:KO stayed silent because the regular session had not opened. The next market-status check was an hour away rather than at the 13:30 UTC open because the loop never sleeps longer than an hour; after 12:30 it wakes exactly at the open.
Before you leave it running
- Run it under a supervisor (systemd, a container restart policy) so a crash restarts it.
- Keep one connection: two copies of the bot double every alert and use two of your WebSocket connections.
- Leave per-message compression off (
compression=None), and keep the reader loop free of slow work. - Watch the log for
stream errorlines:INVALID_CHANNELmeans the channel is not in your plan,INVALID_SYMBOLa typo. - Tune cooldowns per rule: 30 minutes suits a daily move alert, 5 minutes may suit a level on a fast market.
- Keep it an alert, not an order: the bot reads market data and sends messages, nothing else.
Reconnect behaviour, close codes and heartbeats are covered in more depth in the WebSocket reconnect guide and the Python WebSocket client tutorial. If you want the same stream to drive paper trades instead of messages, the Python trading bot tutorial builds that next step, and the signals and alerts use case shows where alert systems usually go from here.
Questions
How do I make a stock alert bot for Discord?
Create a webhook under Server Settings > Integrations > Webhooks, then POST a JSON body with a content field to its URL whenever a price rule fires. The price can come from a REST endpoint or, better, a WebSocket stream of trades.
How do I send crypto price alerts to Telegram?
Create a bot with @BotFather, get your chat id from getUpdates, and call https://api.telegram.org/bot<token>/sendMessage with chat_id and text when your rule triggers.
Why does my alert bot fire the moment it starts?
It is comparing against a stale or replayed price. Ignore snapshot frames for alerting, and require a real crossing between two live trades before a level alert fires.
How do I stop stock alerts outside market hours?
Check the market status endpoint and alert only when status is open. The is_open flag is also true during pre-market and after-hours sessions.
Does a price alert bot need WebSocket access?
Not strictly: slow alerts on a few symbols can poll REST within the free tier. For fast markets or many symbols a stream is cheaper and catches moves that polling misses; WebSocket access comes with paid plans.