A Telegram bot that reads trading chart screenshots with a free vision model on Groq, extracts the trade setup (entry, stop loss, take profit), calculates profit/loss percentages in code, and replies with a formatted signal:
BTCUSD SELL LIMIT
ENTRY: 63552.40
SL: 63717.50
TP: 60361.53
Profit: +5.02% / Loss: -0.26%
The order type is not decorative: the bot reads from the chart whether it's a
MARKET execution (position open now) or a pending LIMIT/STOP (nothing
open until price reaches your entry), monitors each accordingly, and gives you
one tap to correct it if it guessed wrong.
See telegram_bot_roadmap.md for the full design. Automated broker execution
is shelved — see the status note in MT5_MOBILE_AUTOMATION.md.
Read charts
- Turns a chart screenshot (photo or image file) into a signal: asset, direction, entry, SL, TP, and profit/loss % — the math is done in code, never by the AI.
- Tells the five order types apart: BUY LIMIT, BUY STOP, SELL LIMIT, SELL STOP and MARKET execution, judged from the chart itself.
- Reads the most recent position when several are drawn, and points out
another trade on the chart that is already running, with a
👀 Watch this one toobutton to monitor it as well. - Understands broker symbols like Exness's
XAUUSDm,EURUSDcorUS30.cash. - Ignores text messages and images that aren't trading charts.
- Uses a free vision model on Groq, with Gemini's free tier as a backup when Groq's per-minute limit is full (optional key).
Monitor trades live — gold, silver, forex and crypto
- Pending orders: alerts when the order triggers (
✅ XAUUSD BUY STOP triggered), or when price ran to TP without ever filling it. - Open trades: move-SL-to-breakeven alert at 30% of the way to TP, then TP or SL hit alerts.
- Two take-profit levels: at TP1 it says to close half and move SL to entry, then watches for TP2.
- Checks gold, silver and forex every 5 s on a real broker's live bid/ask (Swissquote), and crypto on 1-minute candles (Coinbase) so no wick is missed.
- Any number of trades at once, one request per market however many trades share it.
- Indices and oil (US30, NAS100, WTI…) get the signal but no alerts — no free real-time feed exists for them.
Let you manage trades
- One tap to correct a wrong market/pending guess.
- Reply to any of its messages to change that trade:
sl 4170,be(SL to entry),tp 4195,tp2 4210,entry 4166,cancel. /trades— every monitored trade with its live price and progress (now 4174.00 · +0.19% · 📈 50% of the way to TP)./cancel— stop monitoring one trade or all of them.
Keep you informed
- News warnings: high-impact news (CPI, NFP, rate decisions…) due within 4 hours for that market's currencies is listed on the signal.
- Results:
/statsshows win rate, net result and best/worst assets (last 30 days,/stats 7, or/stats all), plus an automatic summary every Sunday at 20:00 Lagos time.
Run reliably
- Trades and results survive restarts and deploys when Upstash (free) is set up.
- Error alerts to the owner on Telegram, plus a notice whenever the bot
starts (optional
ADMIN_CHAT_ID). - Works in private chats, groups and channels; free hosting on Render.
- An automated test suite runs on every push to GitHub.
-
Create the Telegram bot — message @BotFather, send
/newbot, follow the prompts, and copy the HTTP API token. -
Get a Groq API key (free tier, no card) — sign up at console.groq.com and create an API key.
-
Configure credentials — put both keys in a
.envfile in the project root (never commit this file; it is gitignored):cp .env.example .env # edit .env and paste both keys -
Install dependencies:
python3 -m venv .venv .venv/bin/pip install -r requirements.txt
From the project folder:
.venv/bin/python bot.pyOr, if you prefer activating the virtual environment first:
source .venv/bin/activate
python bot.pyYou should see a log line like:
INFO - __main__ - Bot started, polling for updates...
The bot is now live. Open your bot on Telegram (e.g. @Signal_Texter_bot),
send /start, then send a chart screenshot with a long/short position tool
drawn on it — you'll get the formatted signal back in a few seconds.
-
Stop the bot: press
Ctrl+Cin the terminal. -
Keep it running after closing the terminal (Linux):
nohup .venv/bin/python bot.py >> bot.log 2>&1 &
Check
bot.logfor output; stop it later withpkill -f bot.py.
-
Chart signals — send a chart screenshot, get the formatted signal back.
-
The most recent position wins — charts usually carry several position tools from earlier setups. The bot reads the newest one, judged by which tool's box starts furthest right on the time axis, and ignores the rest. Recency is judged by where a tool starts, not how far right it reaches, so an older position still running to the right edge doesn't win. The number of tools it counted goes to the log, not the chat.
-
Running trades are called out — if another position on the chart is already live (price has passed its entry and is travelling between entry and TP/SL), the bot tells you it's running and gives its levels, on top of the signal for the most recent setup. That one is not monitored — send it as its own chart if you want alerts for it too.
-
Market execution vs pending order — read from the chart: an entry line sitting on the latest candle is a market execution (
BUY MARKET/SELL MARKET), monitored as already open, even if price has moved since the screenshot. Entry away from the market is a pendingLIMITorSTOPthat isn't a position yet, so the bot stays quiet about breakeven/TP/SL until price actually touches your entry, then tells you the order should be filled. If price runs to TP without ever filling, it says so — and, in case you were in after all, that it hit TP. The five order types, from where the entry sits against the price:Setup Entry vs price Order Waits for price to Long below BUY LIMITdrop to entry Long above BUY STOPrise to entry Short above SELL LIMITrise to entry Short below SELL STOPdrop to entry Either on the latest candle BUY MARKET/SELL MARKET— open now Every message names the order:
⏳ BUY STOP — pending order: it waits for price to rise to 4176.00, then✅ XAUUSD BUY STOP triggeredwhen it fills. -
One tap to correct it — the message under each signal carries a button:
✅ I'm already in this tradeon a pending order,⏳ It's a pending orderon an open one. A wrong guess silences every alert that follows, so fixing it shouldn't mean re-sending the chart. -
TP and SL alerts catch wicks — levels are tested against the high and low of every polling interval, not the last traded price, so a spike that tags your stop and snaps back inside the same minute still counts. When one interval covers both TP and SL, the bot reports the SL: which came first is unknowable, and claiming the win would be the worse error.
-
Breakeven alert — once the trade is live, the bot messages you to move SL to breakeven once price covers 30% of the distance from entry to TP.
-
No duplicates, no stale trades — re-sending the same chart won't register the trade twice, and monitoring stops on its own after
TRADE_TTL_HOURS(default 72) if nothing has resolved. -
Change a trade by replying to it — reply to any of the bot's messages about a trade:
sl 4170,be(SL to entry),tp 4195,tp2 4210,entry 4166, orcancel; several at once work (sl 4170 tp2 4210). An edit that would put a level on the wrong side of the entry is refused and changes nothing. Replies reach the bot even with group privacy mode on. -
Two take-profit levels — add a TP2 by replying
tp2 4210. At TP1 the bot says to close half and move the stop to entry, then watches for TP2 with the stop treated as the entry (a return to entry is reported as breakeven, not a loss). -
Watch the running trade too — when a chart also shows a trade already in progress, its note carries a
👀 Watch this one toobutton (when all three of its levels were read and a feed carries the asset). -
News warnings — if high-impact news for the market's currencies is due within 4 hours (ForexFactory's public calendar), the status message lists it. Gold, crypto and US indices count as USD; GER40 as EUR, and so on.
-
Results — every closed trade is logged;
/statsshows win rate, net result and best/worst assets, and every chat that closed a trade gets a summary on Sunday at 20:00 Lagos time. Percentages are price moves from entry (the bot never knows position size); a TP2 trade counts half at TP1.
| Command | What it does |
|---|---|
/start |
Usage hint |
/trades |
Each monitored trade with its live price: now 4174.00 · +0.19% · 📈 50% of the way to TP |
/stats [days|all] |
Results for this chat — last 30 days by default |
/cancel [n|all] |
Stop monitoring one trade (or all of them) |
The command menu is registered with Telegram automatically at startup.
| Instruments | Provider | Sampling | Needs |
|---|---|---|---|
Metals (XAUUSD, XAGUSD, XPTUSD…) |
Swissquote | live bid/ask every 5 s | nothing |
Forex (EURUSD, GBPJPY, USDZAR…) |
Swissquote (Yahoo Finance fallback) | live bid/ask every 5 s | nothing |
Crypto (BTCUSD, ETHUSD, SOLUSD…) |
Coinbase | 1-min high/low every 15 s | nothing |
Indices, oil (US30, NAS100, USOIL…) |
— not monitored | — | see below |
All feeds are public, keyless and free:
- Swissquote (a regulated broker) publishes live bid/ask quotes, under a second old, for metals and forex. Buys are checked on the ask and sells on the bid — the sides a broker fills and closes on — so a short's SL is caught when your broker would hit it, not a spread later when the chart shows it. It publishes no candles, so a wick that reverses between two 5 s polls can still slip past.
- Yahoo Finance is the fallback for forex pairs Swissquote doesn't quote.
- Coinbase covers crypto (
BTCUSD->BTC-USD) with 1-minute candles, so no wick is missed. It lists spot pairs only, so coins that trade solely as perpetuals on derivatives exchanges aren't covered. - Charts no provider carries still get a signal; they just don't get breakeven/TP/SL alerts (the reason goes to the log, not the chat).
Why indices and oil aren't monitored. No free feed tracks index or oil CFD prices in real time. Yahoo's cash indices (
^DJI,^IXIC…) only move during NYSE hours — frozen for ~70% of the day whileUS30trades on — and sit hundreds of points off the CFD price; its futures (YM=F,CL=F) are ~10 minutes delayed. Watching them produced silence or wrong alerts, so the bot sends their signal but doesn't monitor them. A free OANDA practice account would provide real-time index and oil CFD prices if this is needed.
(The previous metals feed, gold-api.com, refreshed only every 30 s and sat
~$1.30 off broker quotes; trades saved under any retired feed move to the
current one automatically.)
- Broker account suffixes are stripped first: Exness's
XAUUSDmorEURUSDc,US30.cash. Only when what remains is recognisably a pair, so a ticker likeETCis left alone. - Metals and forex become Swissquote instruments:
XAUUSD/GOLD->XAU/USD,GBPJPY->GBP/JPY. Yahoo's fallback name isEURUSD=X. - For crypto, Coinbase names products
BASE-QUOTE: a chart labelledBTCUSDisBTC-USDthere. The chart's own quote currency is tried first (ETHUSDT->ETH-USDT, since USDT trades at a small premium to USD), thenUSD,USDTandUSDC; the bot probes each until Coinbase confirms the product exists.
-
The bot responds to chart images and its own commands only. Text messages get no reply, and images that aren't a trading chart with a position tool are silently ignored (the vision model classifies each image before extraction).
-
bot.pypolls Telegram for messages. Photos (and image files) are downloaded and sent to Groq (qwen/qwen3.8-27b) with a prompt describing how to read a TradingView-style position tool (red box = stop loss, green/blue box = take profit, the line between = entry). -
The model comes from a list in
bot.py(VISION_MODELS). Groq currently serves one vision model; the free tier allows 30 requests/min, 1,000/day and 7,000 input tokens/min, and a chart costs ~3,500 tokens, so about two charts a minute go straight through. Past that Groq answers 429 with how long to wait; the bot waits exactly that long and retries, for up to 75 s, so a burst of three charts all get answered (the last in ~45 s) instead of the third failing. -
The model reasons before answering (
VISION_REASONING_EFFORT, defaultmedium), and the prompt tells it to read the levels off the box edges — entry is the edge the red and green boxes share, never the current-price line that crosses them. Without both, it called every chart a SHORT and read the current price as the entry (2 of 6 test charts right); with them, every labelled test chart is read exactly. Charts whose position tool shows no price labels can come back a gridline off, so keep the labels visible. -
The model is forced into JSON mode and given the
ChartAnalysisschema (asset, direction, entry, SL, TP, current price, how many position tools it saw). The reply is validated against that schema, and a mismatch is retried — it never writes the final message and never does math. -
Which position gets read. The prompt tells the model to judge recency by where each position tool's box starts on the time axis and to take the right-most one, ignoring every other tool and drawing on the chart. It also reports the total it counted — asking for a count makes it look at all of them before choosing, and the number goes to the log rather than the chat — and, separately, any position price is currently inside, which is what the running-trade note is built from.
-
The backend calculates the percentages deterministically (per the roadmap formulas), classifies the order, validates that the setup is coherent, and formats the reply.
-
The screenshot classifies the order, not the live price. The chart is the moment the trade was taken, so it decides, in this order:
- where the model saw the entry line sit against the latest candle — on it (market), above or below it (pending). Judging which of two lines is higher is far more reliable than reading both labels and subtracting;
- the chart's price label: entry within
CHART_MARKET_TOLERANCE(5 basis points, ~$2 on gold) is a market execution; - only if the chart gives neither, the live price, within
MARKET_ORDER_TOLERANCE(1 basis point).
The live price used to decide first, and it turned most market trades into phantom pending orders: by the time a chart arrives, gold has moved more than 1 basis point ($0.42), so the bot waited for price to return to an entry you were already in — and when price ran to TP, never sent the breakeven or TP alert at all. With no signal anywhere the bot names the side but not the order type, rather than guessing
LIMITvsSTOP, and settles which side of the market the entry sits on from the first price reading. -
Monitoring samples ranges, not points. Every 15 s a background job asks each candle provider (Coinbase, Yahoo) for the 1-minute candles covering the time since that trade was last checked, and tests TP/SL/breakeven against the interval's high and low. Comparing only the last traded price meant a level counted solely if price was still beyond it at the instant of the poll, so wicks were invisible and trades ran on past their stops. The lookback is bounded by the trade's own
created_at, so a wick from before you sent the chart can't resolve it, and capped atMAX_LOOKBACK_MINUTES(180) so waking from a long sleep doesn't request a day of history. Each trade remembers the newest candle it has used and picks up from there, rather than from the clock time of its last check: Yahoo publishes a minute's high/low late, and a window moving with a 15 s clock would slide past that minute before it filled in. -
One request per market, all markets at once. Trades are grouped by symbol, so ten trades on
BTC-USDcost one request, and every market is fetched in parallel with aFEED_TIMEOUT(10 s) cap each. The number of trades has no limit; a cycle takes as long as the slowest single feed, not the sum of all of them. (Checking trades one at a time used to let a few slow feeds overrun the interval, and the scheduler then silently skipped whole cycles.) A feed that fails or times out only delays its own trades, which catch up on the next cycle. -
One bad trade can't silence the rest. Each trade is checked inside its own
try, because an unexpected key used to raise mid-loop and abort the cycle for every trade in every chat, every minute, indefinitely. -
Pair resolution probes each candidate pair with a single-symbol price call, in provider order (Swissquote, Yahoo, Coinbase); the first that answers is the trade's feed. Trades saved under older routing (Bybit, GoldAPI, Yahoo indices) are moved to the current feed automatically on the next monitoring cycle, or dropped with a message saying why.
Telegram bots never talk to users directly — everything goes through Telegram's Bot API servers, authenticated by the bot token from BotFather.
You (Telegram app) Telegram servers This bot
| | |
| 1. send chart photo | |
|-------------------------------->| |
| | 2. "update" (JSON) |
| |--------------------------->|
| | 3. getFile + download |
| |<---------------------------|
| | (bot sends image to Groq,
| | does the math locally)
| | 4. sendMessage (signal) |
| |<---------------------------|
| 5. signal appears in chat | |
|<--------------------------------| |
- You send a photo to the bot chat. Telegram stores the image on its servers and creates an update — a JSON object describing the new message.
- The bot receives the update in one of two ways (chosen automatically
in
main()):- Polling (local runs): the bot repeatedly calls
getUpdates, a long-poll HTTP request that returns as soon as something arrives. Outbound-only — works behind any firewall, no public URL needed. - Webhook (on Render): the bot registers its public URL with Telegram
once at startup (
setWebhook), and Telegram then POSTs each update tohttps://<app>.onrender.com/telegram. Requests are verified with a secret token so only Telegram can trigger the bot. Failed deliveries (e.g. while the free service wakes from sleep) are retried by Telegram.
- Polling (local runs): the bot repeatedly calls
- The bot downloads the image via the Bot API (
getFile), since updates only carry a file reference, not the image bytes themselves. - The bot replies with
sendMessageto the chat the photo came from (chat_idin the update). Handler routing inmain()decides what runs: photos/image files → chart analysis;/start→ the welcome text; anything else → no handler, so no reply. - Push messages without an incoming message: for breakeven/TP/SL alerts
the bot calls
sendMessageon its own using thechat_ids it saved instate.json— a bot may message any chat where the user has already started a conversation with it. (This also means the bot cannot message anyone who has never opened it — Telegram forbids unsolicited first contact.)
- One consumer at a time: a bot token supports either an active webhook or polling — not both at once. That's why local runs require stopping the Render deployment first (see Switching back to local runs below).
- Groups — read the next section. The bot analyses a chart from any sender, but Telegram's privacy mode stops most of them ever reaching it.
- Photos vs. files: Telegram re-compresses photos to JPEG; sending the screenshot as a file/document preserves full quality, which can help the model read small price labels. The bot accepts both.
The bot has never cared who sent a photo — there is no sender filter in the code, and any member's chart is analysed the same way. What gets in the way is Telegram's privacy mode, which is ON by default for every new bot. While it is on, a bot in a group is only delivered commands, @mentions, and replies to its own messages. Other members' photos never reach it at all, so there is nothing for the code to respond to.
There is no Bot API call that can change this — it is a BotFather setting:
- Open @BotFather
/setprivacy→ choose your bot → Disable- Remove the bot from the group and add it back. The change only applies on re-join; existing memberships keep the old setting.
The bot checks this for you at startup via getMe and logs which state it is
in:
Privacy mode is OFF — every member's charts are visible in groups
If privacy mode is still on it logs a warning instead, and /start in a group
replies with the steps above — so this fails loudly rather than looking like a
bot that just ignores people.
The bot has two modes, picked automatically:
- Locally (no
RENDER_EXTERNAL_URL/WEBHOOK_URLset): long polling. - On Render: webhook mode — Telegram POSTs each message to your Render URL.
- Push this repo to GitHub (already done if you followed along).
- Sign up at render.com (free, no card needed) and
choose New → Blueprint, then connect the GitHub repo. Render reads
render.yamland creates the service automatically.- Or manually: New → Web Service, pick the repo, runtime Python,
build command
pip install -r requirements.txt, start commandpython bot.py, instance type Free.
- Or manually: New → Web Service, pick the repo, runtime Python,
build command
- When prompted, set the environment variables:
TELEGRAM_BOT_TOKEN,GROQ_API_KEY— required- live prices need no key — every feed the bot uses is public
- Deploy. Once live, the bot registers its own webhook with Telegram —
no manual webhook setup needed. Send
/startto the bot to confirm.
A free service sleeps after 15 minutes idle, and a sleeping service runs no monitoring job at all. Incoming Telegram messages wake it (the first reply takes ~30–60 s; Telegram retries, so nothing is lost), but alerts need it awake continuously. Add a free uptime pinger:
- Sign up at uptimerobot.com (free) — or cron-job.org.
- Add an HTTP(S) monitor pointing at your Render URL
(
https://<your-app>.onrender.com/) with a 5-minute interval.
The pings stop Render idling the service. One always-on service uses ~730 of the free plan's 750 instance-hours per month, so it fits.
Monitored trades, results and the chat list are written to state.json
(gitignored — it holds your chat IDs and positions) through a temp file and an
atomic rename, so a crash mid-write can't truncate it. On Render's free tier
that file is wiped on every redeploy and restart.
To keep trades through restarts, set UPSTASH_REDIS_REST_URL and
UPSTASH_REDIS_REST_TOKEN (a free Redis database at
console.upstash.com → your database → REST API).
The bot then loads its state from Upstash at startup and saves to it in the
background whenever a trade changes — not on every price poll, so it stays far
inside the free 500K requests a month. Use the normal REST token, not the
read-only one: at startup the bot checks it can write, and if it can't, the
start-up notice says so (with ADMIN_CHAT_ID set) instead of silently losing
every save. If Upstash can't be read at startup, the bot refuses to write to
it for that run, so a network blip can never replace your stored trades with
an empty list.
Deploys are safe. Render starts the new copy of the bot before stopping
the old one, so for a minute or two both run. Each trade and each result is
stored separately, and a copy only writes what it changed, so neither copy
can erase a trade the other registered. Every 20 s each copy picks up trades
the other added, changed or closed, and an alert is sent by whichever copy
claims it first in Upstash — never twice. /stats reads results from Upstash,
so results recorded by either copy count.
| Variable | Turns on |
|---|---|
UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN |
Trades and results survive restarts |
GEMINI_API_KEY |
Backup chart reader: a chart that finds Groq's per-minute limit full goes to Gemini's free tier instead of waiting |
ADMIN_CHAT_ID |
Your Telegram user id (@userinfobot): every logged error is messaged to you, batched every 30 s with repeats silenced for 30 min, plus a notice whenever the bot starts |
pip install -r requirements-dev.txt
pytest -q # offline, under a second
RUN_LIVE_VISION=1 pytest tests/test_live_vision.py # real chart reading, ~4 minThe offline suite fakes every external service — the vision model, price
feeds, Upstash and Telegram — and covers order types, TP/SL/breakeven
detection, TP2, reply edits, results, saving, the backup reader, news and
whole chart flows. It never touches the real Upstash database, even with its
keys in .env. The live test draws one chart per order type (BUY/SELL ×
LIMIT/STOP/MARKET) and checks the real model reads every level exactly.
GitHub Actions runs the offline suite on every push. The live test runs only
when started by hand from the Actions tab with "live" ticked, and needs the
GROQ_API_KEY repository secret.
A TP or SL alert didn't arrive. Check, in order:
- Is the trade still registered? Send
/trades. If it isn't listed, it isn't being watched — without Upstash set up, a free-tier redeploy wipes it. Re-send the chart to re-register. - Was the service awake? A sleeping service runs no monitoring job at
all. On waking, the next cycle looks back over the gap (up to
MAX_LOOKBACK_MINUTES, 180) and will still report a level crossed while it slept — but only up to that cap, so set up the uptime pinger. - Was it still pending? A
⏳trade has not filled, and pending orders deliberately get no TP/SL alerts until price touches your entry. - Was it read as pending when you were already in (or the reverse)? The message under the signal says which; tap its button to correct it.
- Is it gold, silver or forex? Swissquote publishes quotes, not candles, so a wick that reverses between 5 s polls can be missed. Crypto is checked on 1-minute high/low and cannot miss one.
- Is it an index or oil? These get the signal but are never monitored,
and aren't listed in
/trades. The log saysNot monitoring <asset>. - Check the logs. Every event logs its sample, e.g.
XAUUSD SHORT -> SL (last 4310.93, range 4309.82-4310.93 since 09:41:00). A trade that keeps failing logsMonitoring failed for <asset> in chat <id>with a traceback each cycle, without affecting any other trade.
The bot ignores charts from other people in my group. Telegram privacy mode is still on — see Reading every member's charts in a group. Remember the remove-and-re-add step; disabling it in BotFather alone does nothing for a group the bot is already in.
It read the wrong position off my chart. The bot takes the right-most position tool. If two start at nearly the same point the model can pick the other one — crop the screenshot to the setup you mean. When it sees more than one tool it tells you how many it found.
A signal arrived but the trade isn't in /trades. It isn't being
monitored: either it's an index or oil (see Which assets can be monitored),
or no feed lists that symbol — make sure the chart shows a standard ticker
(e.g. XAUUSD, EURUSD, BTCUSD). The log line Not monitoring <asset>
says which.
Push to the GitHub repo's main branch — Render redeploys automatically.
Just run .venv/bin/python bot.py on your machine. Stop the Render service
first (or before that, run local polling will fail) — Telegram allows either
one webhook or one polling consumer, not both. Delete the webhook manually if
needed:
curl "https://api.telegram.org/bot<TOKEN>/deleteWebhook"