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
  1. Stream, don't poll
  2. What the Discord stock bot does, in order
  3. Part 1: rules and state
  4. Part 2: market hours without a timer
  5. Part 3: the stream and the rule check
  6. Part 4: Discord webhook and Telegram Bot API adapters
  7. Connect Discord and Telegram, then run it
  8. Before you leave it running
  9. Questions

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.

FeaturePolling REST every 10 sOne WebSocket stream
Requests for 5 symbols, per day43,200About 30 (closes and market status)
Sees a spike that reverses in 5 seconds
Works on the free tieryes, for a few slow symbolsno, WebSocket is on paid plans
Delay between trade and alertUp to one polling intervalNetwork and processing time only
Moving partsA timerA reconnect loop
Free accounts can request a WebSocket trial from the dashboard. For the trade-offs in depth, see WebSocket vs REST.

What the Discord stock bot does, in order

BotTickerLayerDiscord / Telegram
  1. GET /markets/status?asset=stocks&market=USregular session open?Bot to TickerLayer
  2. GET /{asset}/snapshot/{symbol}prev_close for move rulesBot to TickerLayer
  3. WebSocket connect, wait for readyBot to TickerLayer
  4. subscribe crypto.trades, stocks.tradesBot to TickerLayer
  5. trade framesTickerLayer to Bot
  6. rule check: crossing, % move, cooldown
  7. POST webhook / sendMessageBot to Discord / Telegram
Sending happens on a separate task, so a slow chat API never delays reading the socket.
RuleFires whenExample
abovePrevious trade was below the level and this trade is at or above itRule("BTCUSD", "above", 90_000)
belowPrevious trade was above the level and this trade is at or below itRule("US:KO", "below", 87.50)
moveChange from the previous close reaches the threshold, up or downRule("US:KO", "move", 2.0) for ±2%
All rulesNot 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.

alert_bot.py (part 1 of 4)Python
"""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 session

Part 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
}
  1. statusopen only in the regular session. Also pre_market, post_market, closed.
  2. is_openTrue whenever any session applies, pre-market included. Not the flag you want for alerts.
  3. next_openUTC instant the regular session next opens. The bot wakes just after it.
  4. next_closeUTC instant the current or next regular session ends.
GET /markets/status?asset=stocks&market=US at 10:25 UTC on 2026-09-28, trimmed.
alert_bot.py (part 2 of 4)Python
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.

alert_bot.py (part 3 of 4)Python
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.

alert_bot.py (part 4 of 4)Python
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

  1. Discord webhookIn your server: Server Settings > Integrations > Webhooks > New Webhook, pick the channel, then Copy Webhook URL. Export it as DISCORD_WEBHOOK_URL.
  2. Telegram botMessage @BotFather, send /newbot, and keep the token as TELEGRAM_BOT_TOKEN. Send your bot a message, open https://api.telegram.org/bot<token>/getUpdates, and copy message.chat.id into TELEGRAM_CHAT_ID.
  3. 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.
  4. Runpip install websockets requests, then python alert_bot.py. With no chat variables set it prints alerts to the terminal.
Output (dry run, 2026-09-28, US market in pre-market)
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 error lines: INVALID_CHANNEL means the channel is not in your plan, INVALID_SYMBOL a 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.

Keep reading

Ready to integrate?

Start with the free tier, explore the docs, and connect via REST or WebSocket in minutes.