A Telegram bot that reads trading chart screenshots with Gemini Flash vision, 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 checks the live price to tell a
MARKET execution (position open now) from a pending LIMIT/STOP (nothing
open until price reaches your entry), and monitors each accordingly.
See telegram_bot_roadmap.md for the full design. Automated broker execution
is shelved — see the status note in MT5_MOBILE_AUTOMATION.md.
-
Create the Telegram bot — message @BotFather, send
/newbot, follow the prompts, and copy the HTTP API token. -
Get a Gemini API key (free tier) — go to Google AI Studio 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 — the bot fetches the live price
and compares it to your entry. Entry sitting on the market is a market
execution (
BUY MARKET/SELL MARKET), monitored as already open. 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 the setup played out without you instead of claiming a win you never took. - 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.
| Command | What it does |
|---|---|
/start |
Usage hint |
/trades |
One line per monitored trade: ⏳ pending · 🔴 live · 🔒 at breakeven |
/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 |
|---|---|---|---|
Spot Gold & Silver (XAUUSD, XAGUSD) |
GoldAPI (gold-api.com) |
spot price every 60 s | nothing |
Forex, Indices, Oil (EURUSD, US30, USOIL…) |
Yahoo Finance | 1-min high/low | nothing |
Crypto (BTCUSD, ETHUSD, SOLUSD…) |
Bybit | 1-min high/low | nothing |
Metals are the one gap.
gold-api.compublishes a spot price and nothing else — it has no OHLC endpoint — soXAUUSDandXAGUSDare still sampled pointwise once a minute and can miss a TP/SL wick that reverses within that minute. Every other instrument gets true interval high/low and cannot miss one.
The providers work together automatically with zero required broker credentials or API keys:
- GoldAPI is checked first for spot metals (
XAUUSD->XAU,XAGUSD->XAG). This uses real-time spot prices rather than COMEX futures so your Stop Loss and Take Profit levels match spot CFD broker charts exactly. - Yahoo Finance is checked next for traditional markets, covering forex pairs (
EURUSD=X), major stock indices (^DJI,^GSPC,^IXIC,^GDAXI), and oil (CL=F,BZ=F). - Bybit is keyless and unmetered, handling crypto (
BTCUSD,ETHUSD...) and spot CFD fallbacks. - Charts neither provider carries still get a signal; they just don't get breakeven/TP/SL alerts.
- For spot metals, the bot maps
XAUUSD/GOLDtoXAUandXAGUSD/SILVERtoXAGongold-api.com. - For other traditional assets, the bot maps chart tickers to public Yahoo Finance symbols (e.g.
US30->^DJI,EURUSD->EURUSD=X). - For crypto, Bybit names pairs its own way: a chart labelled
BTCUSDisBTCUSDTthere. Rather than guess a suffix, the bot probes Bybit's API to confirm the live ticker 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 (Gemini classifies each image before extraction).
-
bot.pypolls Telegram for messages. Photos (and image files) are downloaded and sent to Gemini Flash 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 is picked from a fallback list in
bot.py(GEMINI_MODELS) — newest Flash first, older ones as backup — because free-tier models occasionally return "high demand" errors. -
Gemini is forced to return structured JSON only (asset, direction, entry, SL, TP, current price, how many position tools it saw) via a response schema — 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 live feed classifies the order, not the screenshot. Before replying, the bot resolves the asset's price feed and fetches the current price, then compares it to the entry. Gemini reads the chart's own price label well enough most of the time, but that label is a moment that has already passed, and when it's small or occluded the model returns null — which used to make every such setup look like a market execution. Entry within
MARKET_ORDER_TOLERANCEof the live price is a market execution and starts live; anything else is a pendingLIMIT/STOPthat starts pending and waits for price to reach entry before any breakeven/TP/SL alert can fire.The tolerance is deliberately tight (1 basis point) because the two misreadings don't cost the same. Calling a market order "pending" is cheap — price is already on the entry, so the fill fires on the next poll a minute later. Calling a limit order "market" is expensive: the bot believes you're in a position you never opened and starts sending breakeven and TP/SL alerts for it. When in doubt it treats the order as pending.
If no feed carries the asset, the model's visual read decides instead — it reports whether the entry line sits above, below, or on the current price level. Judging which of two lines is higher is far more reliable than reading both prices off the axis and subtracting. With neither signal the bot names the side but not the order type, rather than guessing
LIMITvsSTOP. -
Monitoring samples ranges, not points. Every 60 s a background job asks each provider 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. -
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 downloads Bybit's full symbol list per market category (cached 6 h) and matches the chart's asset against it, preferring
linear(USDT perpetuals) overspot. Per-cycle pricing then uses a targeted single-symbol call, because the full linear ticker payload is ~550 KB and fetching that every minute would be wasteful.
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 Gemini,
| | 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 Gemini 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,GEMINI_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 and the chat list are stored in state.json (gitignored — it
holds your chat IDs and open positions, so don't commit it). Writes go through
a temp file and an atomic rename, so a crash mid-write can't truncate it.
On the free tier this file is wiped on every redeploy and restart —
monitored trades are forgotten, and no breakeven/TP/SL alert can fire for a
trade registered before the last deploy. Re-send the chart to re-register.
After the alert fixes, this is the most likely reason a real alert never
arrives, so check /trades first.
To make trades survive, Render requires a paid instance (~$7/month) — disks
are not offered on free instances. Switch render.yaml to plan: starter and
add:
disk:
name: bot-state
mountPath: /var/data
sizeGB: 1
envVars:
- key: STATE_FILE
value: /var/data/state.jsonA 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 — a free-tier redeploy wipesstate.json. 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. - Is it gold or silver?
gold-api.comhas no OHLC endpoint, so metals are sampled pointwise and a wick that reverses inside the minute can be missed. Everything else is checked on interval high/low and cannot miss one. - Check the logs. Every event logs its sample, e.g.
XAUUSD SHORT -> SL (last 4310.93, range 4309.82-4310.93 over 2m). 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 trade says monitoring is unavailable. No feed lists that asset symbol. Make sure the asset is a standard ticker symbol (e.g. XAUUSD, EURUSD, US30, BTCUSD).
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"