Files
ForexBot2/telegram_bot.py
T
Harwinder 71c3fb6d82 Add Telegram bot and fix excessive API calls
- Add telegram_bot.py: full bot interface with /scan, /signals, /pair,
  /all, /tf, /watch, /unwatch, /status, /help commands; per-chat state;
  background thread pool for analysis; auto message splitting
- Fix data_provider: warn once per symbol with no API data and cache
  synthetic fallback to prevent redundant individual API calls
- Fix signal_aggregator: derive spread estimate from already-fetched
  primary DataFrame instead of making a separate M5 API call per pair
- Add python-telegram-bot[job-queue]>=20.0 to requirements.txt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 15:15:04 -04:00

589 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Forex Signal Intelligence Platform - Telegram Bot
===================================================
Exposes all platform functionality through a Telegram bot interface.
Usage:
export TELEGRAM_BOT_TOKEN="your_bot_token_here"
python telegram_bot.py
Commands:
/scan [TF] — Scan all major pairs
/signals [TF] — Only actionable signals (grade B+)
/pair EURUSD [TF] — Detailed analysis of one pair
/all [TF] — Scan major + cross pairs
/tf H1 — Set default timeframe
/watch [minutes] — Enable periodic scan alerts
/unwatch — Disable alerts
/status — Current settings
/help — Full command reference
"""
import asyncio
import logging
import os
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from typing import Dict, Optional
from telegram import Update
from telegram.constants import ParseMode
from telegram.ext import Application, CallbackContext, CommandHandler, ContextTypes
from config import (
CROSS_PAIRS, MAJOR_PAIRS, SYMBOL_DISPLAY,
PlatformConfig, SignalStrength, Timeframe,
)
from signal_aggregator import SignalAggregator, SignalReport
logger = logging.getLogger(__name__)
# ── Constants ──────────────────────────────────────────────────────
TF_MAP = {
"M5": Timeframe.M5,
"M15": Timeframe.M15,
"M30": Timeframe.M30,
"H1": Timeframe.H1,
"H4": Timeframe.H4,
"D1": Timeframe.D1,
}
TF_CONFIRMATIONS = {
"M5": [Timeframe.M1, Timeframe.M15],
"M15": [Timeframe.M5, Timeframe.H1],
"M30": [Timeframe.M15, Timeframe.H1],
"H1": [Timeframe.M15, Timeframe.H4],
"H4": [Timeframe.H1, Timeframe.D1],
"D1": [Timeframe.H4, Timeframe.W1],
}
SIGNAL_EMOJI = {
SignalStrength.STRONG_BUY: "🟢⬆⬆",
SignalStrength.BUY: "🟢⬆",
SignalStrength.NEUTRAL: "⬜↔",
SignalStrength.SELL: "🔴⬇",
SignalStrength.STRONG_SELL: "🔴⬇⬇",
}
SIGNAL_NAME = {
SignalStrength.STRONG_BUY: "STRONG BUY",
SignalStrength.BUY: "BUY",
SignalStrength.NEUTRAL: "NEUTRAL",
SignalStrength.SELL: "SELL",
SignalStrength.STRONG_SELL: "STRONG SELL",
}
GRADE_EMOJI = {
"A+": "⭐⭐",
"A": "⭐",
"B": "🔵",
"C": "🟡",
"D": "🟠",
"F": "⚫",
}
WATCH_MIN_INTERVAL = 5 # minimum watch interval in minutes
WATCH_MAX_INTERVAL = 120 # maximum watch interval in minutes
WATCH_DEFAULT = 5
# ── Per-chat state ─────────────────────────────────────────────────
class ChatState:
def __init__(self):
self.timeframe: str = "H1"
self.pairs: list = list(MAJOR_PAIRS)
self.watch_enabled: bool = False
# ── HTML helpers ───────────────────────────────────────────────────
def _esc(text: str) -> str:
"""Escape HTML special characters for Telegram HTML parse mode."""
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def _split_messages(lines: list[str], limit: int = 4000) -> list[str]:
"""Join lines into messages that stay under Telegram's 4096-char limit."""
messages, current, current_len = [], [], 0
for line in lines:
line_len = len(line) + 1 # +1 for newline
if current and current_len + line_len > limit:
messages.append("\n".join(current))
current, current_len = [], 0
current.append(line)
current_len += line_len
if current:
messages.append("\n".join(current))
return messages
# ── Message formatters ─────────────────────────────────────────────
def _format_scan(reports: list[SignalReport], timeframe: str, title: str = "FOREX SIGNAL SCAN") -> str:
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
lines = [
f"<b>📊 {title} — {timeframe}</b>",
"<code>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</code>",
f"<code>🕐 {now}</code>\n",
]
for r in reports:
sig_em = SIGNAL_EMOJI.get(r.final_signal, "❔")
grade_em = GRADE_EMOJI.get(r.signal_quality, "")
sig_name = SIGNAL_NAME.get(r.final_signal, "?")
event_flag = " ⚠️" if r.event_risk == "danger" else (" ⚡" if r.event_risk == "caution" else "")
lines.append(
f"{sig_em} <b>{_esc(r.display_name)}</b> "
f"{grade_em}<code>{r.signal_quality}</code> "
f"<code>{_esc(sig_name):<11}</code> "
f"<code>{r.final_confidence:.0f}%</code>{event_flag}"
)
lines += ["", "<i>Use /pair EURUSD for detailed analysis</i>"]
return "\n".join(lines)
def _format_pair_detail(r: SignalReport) -> list[str]:
"""
Return a list of message strings for a single pair.
Split across messages so each stays under Telegram's 4096-char limit.
"""
sig_em = SIGNAL_EMOJI.get(r.final_signal, "❔")
grade_em = GRADE_EMOJI.get(r.signal_quality, "")
sig_name = SIGNAL_NAME.get(r.final_signal, "?")
# ── Message 1: Header, MTF, Trade Setup, Key Levels ───────────
m1 = [
f"{sig_em} <b>{_esc(r.display_name)}</b> — <b>{_esc(sig_name)}</b>",
"<code>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</code>",
f"<b>Price:</b> <code>{r.current_price:.5f}</code>",
f"<b>Regime:</b> {_esc(r.market_regime)} <b>Vol:</b> {_esc(r.volatility_regime)}",
"",
f"<b>Score:</b> <code>{r.final_score:+.1f}</code> "
f"<b>Grade:</b> {grade_em}<code>{r.signal_quality}</code> "
f"<b>Conf:</b> <code>{r.final_confidence:.0f}%</code>",
f"<b>Action:</b> {_esc(r.action_recommendation)}",
]
if r.event_risk != "clear":
icon = "⚠️" if r.event_risk == "danger" else "⚡"
m1.append(f"{icon} <b>Event:</b> {_esc(r.event_description)}")
if r.news_description and r.news_description != "Neutral news sentiment":
m1.append(f"📰 <b>News:</b> {_esc(r.news_description)}")
# Multi-timeframe
if r.mtf_signals:
agree = "✅ ALIGNED" if r.mtf_agreement else "⚡ DIVERGENT"
m1 += ["", f"<b>📊 Multi-Timeframe:</b> {agree}"]
for tf, sig in r.mtf_signals.items():
score = r.mtf_scores.get(tf, 0)
m1.append(
f" <code>{tf:>5}</code> {SIGNAL_EMOJI.get(sig, '?')} "
f"<code>{score:+.1f}</code>"
)
# Trade setup
if r.trade_setup:
ts = r.trade_setup
dir_em = "🟢" if ts.direction == "BUY" else "🔴"
m1 += [
"",
"<b>💼 Trade Setup</b>",
f" <b>Direction:</b> {dir_em} {ts.direction}",
f" <b>Entry:</b> <code>{ts.entry_price:.5f}</code>",
f" <b>Stop Loss:</b> <code>{ts.stop_loss:.5f}</code> ({ts.risk_pips:.1f} pips)",
f" <b>Take Profit 1:</b> <code>{ts.take_profit_1:.5f}</code> ({ts.reward_pips:.1f} pips)",
f" <b>Take Profit 2:</b> <code>{ts.take_profit_2:.5f}</code>",
f" <b>Risk:Reward:</b> 1:{ts.risk_reward:.1f}",
]
# Key levels
if r.primary_analysis and r.primary_analysis.key_levels:
lvls = r.primary_analysis.key_levels
shown = [(k, lvls[k]) for k in ("r3", "r2", "r1", "pivot", "s1", "s2", "s3") if k in lvls]
if shown:
m1 += ["", "<b>📍 Key Levels:</b>"]
for k, v in shown:
arrow = "🔴" if v > r.current_price else "🟢"
m1.append(f" {arrow} <code>{k.upper():>6}:</code> <code>{v:.5f}</code>")
# ── Message 2: Indicators + Reasons ───────────────────────────
m2 = []
if r.primary_analysis:
ind_lines = []
for name, ind in r.primary_analysis.indicators.items():
if name.startswith("_") or ind.weight == 0:
continue
em = SIGNAL_EMOJI.get(ind.signal, "↔")
# Trim long descriptions to keep message readable
desc = _esc(ind.description[:90])
ind_lines.append(
f" {em} <b>{_esc(ind.name)}</b> <code>{ind.confidence:.0f}%</code>"
f"\n <i>{desc}</i>"
)
if ind_lines:
m2 += ["<b>📉 Indicators:</b>"] + ind_lines
if r.bullish_reasons:
if m2:
m2.append("")
m2.append("<b>🟢 Bullish Factors:</b>")
for reason in r.bullish_reasons[:5]:
m2.append(f" ▲ {_esc(reason)}")
if r.bearish_reasons:
if m2:
m2.append("")
m2.append("<b>🔴 Bearish Factors:</b>")
for reason in r.bearish_reasons[:5]:
m2.append(f" ▼ {_esc(reason)}")
if r.warnings:
if m2:
m2.append("")
m2.append("<b>⚠️ Warnings:</b>")
for w in r.warnings:
m2.append(f" ! {_esc(w)}")
result = _split_messages(m1)
if m2:
result += _split_messages(m2)
return result
# ══════════════════════════════════════════════════════════════════
# BOT CLASS
# ══════════════════════════════════════════════════════════════════
class ForexTelegramBot:
def __init__(self):
self._chat_states: Dict[int, ChatState] = {}
self._executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="forex")
def _state(self, chat_id: int) -> ChatState:
if chat_id not in self._chat_states:
self._chat_states[chat_id] = ChatState()
return self._chat_states[chat_id]
def _build_config(self, state: ChatState) -> PlatformConfig:
config = PlatformConfig()
config.primary_timeframe = TF_MAP[state.timeframe]
config.confirmation_timeframes = TF_CONFIRMATIONS.get(
state.timeframe, [Timeframe.M15, Timeframe.H4]
)
config.watch_pairs = list(state.pairs)
return config
# ── Blocking analysis (run in thread pool) ─────────────────────
def _run_scan(self, state: ChatState) -> list[SignalReport]:
config = self._build_config(state)
return SignalAggregator(config).analyze_all_pairs()
def _run_pair(self, symbol: str, state: ChatState) -> Optional[SignalReport]:
config = self._build_config(state)
config.watch_pairs = [symbol]
reports = SignalAggregator(config).analyze_all_pairs()
return reports[0] if reports else None
async def _analyze(self, func, *args):
"""Run a blocking analysis function in the thread pool."""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(self._executor, func, *args)
# ── Parse helpers ──────────────────────────────────────────────
@staticmethod
def _parse_tf(args: list[str]) -> Optional[str]:
"""Return the first arg that is a valid timeframe key, or None."""
for a in args:
if a.upper() in TF_MAP:
return a.upper()
return None
# ── Commands ───────────────────────────────────────────────────
async def cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
text = (
"<b>🌐 Forex Signal Intelligence Platform</b>\n"
"<code>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</code>\n\n"
"Multi-layer technical + fundamental signal analysis across major Forex pairs.\n\n"
"<b>Commands:</b>\n"
" /scan — Scan all major pairs\n"
" /signals — Actionable signals only (grade B+)\n"
" /pair EURUSD — Detailed single-pair analysis\n"
" /all — Include cross pairs in scan\n"
" /tf H1 — Set default timeframe\n"
" /watch — Periodic scan alerts (every 5 min)\n"
" /unwatch — Disable alerts\n"
" /status — Current settings\n"
" /help — Full command reference\n\n"
"<i>⚠️ Educational/research use only. Not financial advice.</i>"
)
await update.message.reply_text(text, parse_mode=ParseMode.HTML)
async def cmd_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
text = (
"<b>📖 Command Reference</b>\n\n"
"<b>/scan</b> — Scan all major pairs (uses default TF)\n"
"<b>/scan H4</b> — Scan with a specific timeframe\n\n"
"<b>/signals</b> — Grade B+ signals only\n"
"<b>/signals H4</b> — With a specific timeframe\n\n"
"<b>/pair EURUSD</b> — Detailed analysis (uses default TF)\n"
"<b>/pair EURUSD H4</b> — With a specific timeframe\n\n"
"<b>/all</b> — Scan major + cross pairs\n"
"<b>/all H4</b> — With a specific timeframe\n\n"
"<b>/tf H1</b> — Set your default timeframe\n"
" Options: <code>M5 M15 M30 H1 H4 D1</code>\n\n"
"<b>/watch</b> — Alert every 5 min (actionable signals only)\n"
"<b>/watch 15</b> — Alert every 15 min (5–120 min range)\n"
"<b>/unwatch</b> — Stop alerts\n\n"
"<b>/status</b> — View current settings\n\n"
"<b>Signal key:</b>\n"
" 🟢⬆⬆ Strong Buy 🟢⬆ Buy ⬜↔ Neutral\n"
" 🔴⬇ Sell 🔴⬇⬇ Strong Sell\n\n"
"<b>Grade key:</b>\n"
" A+ ⭐⭐ A ⭐ B 🔵 C 🟡 D 🟠 F ⚫"
)
await update.message.reply_text(text, parse_mode=ParseMode.HTML)
async def cmd_status(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
state = self._state(update.effective_chat.id)
pairs_display = ", ".join(SYMBOL_DISPLAY.get(p, p) for p in state.pairs)
watch_str = "✅ enabled" if state.watch_enabled else "❌ disabled"
text = (
"<b>⚙️ Current Settings</b>\n\n"
f"<b>Timeframe:</b> {state.timeframe}\n"
f"<b>Pairs:</b> {_esc(pairs_display)}\n"
f"<b>Watch alerts:</b> {watch_str}"
)
await update.message.reply_text(text, parse_mode=ParseMode.HTML)
async def cmd_tf(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
tf = self._parse_tf(context.args or [])
if not tf:
opts = " ".join(TF_MAP.keys())
await update.message.reply_text(
f"Usage: <b>/tf &lt;timeframe&gt;</b>\nOptions: <code>{opts}</code>",
parse_mode=ParseMode.HTML,
)
return
self._state(update.effective_chat.id).timeframe = tf
await update.message.reply_text(
f"✅ Default timeframe set to <b>{tf}</b>", parse_mode=ParseMode.HTML
)
async def cmd_scan(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
chat_id = update.effective_chat.id
state = self._state(chat_id)
# Allow inline TF override: /scan H4
scan_state = ChatState()
scan_state.timeframe = self._parse_tf(context.args or []) or state.timeframe
scan_state.pairs = list(state.pairs)
msg = await update.message.reply_text("🔄 Scanning markets, please wait…")
try:
reports = await self._analyze(self._run_scan, scan_state)
text = _format_scan(reports, scan_state.timeframe)
await msg.edit_text(text, parse_mode=ParseMode.HTML)
except Exception as e:
logger.error(f"Scan error: {e}")
await msg.edit_text(f"❌ Scan failed: {_esc(str(e))}", parse_mode=ParseMode.HTML)
async def cmd_signals(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
chat_id = update.effective_chat.id
state = self._state(chat_id)
scan_state = ChatState()
scan_state.timeframe = self._parse_tf(context.args or []) or state.timeframe
scan_state.pairs = list(state.pairs)
msg = await update.message.reply_text("🔄 Scanning for signals…")
try:
reports = await self._analyze(self._run_scan, scan_state)
actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B")]
if actionable:
text = _format_scan(actionable, scan_state.timeframe, title="🎯 ACTIONABLE SIGNALS")
else:
text = (
f"<b>🎯 ACTIONABLE SIGNALS — {scan_state.timeframe}</b>\n\n"
"No grade B+ signals right now.\n"
"Use /scan to see all pairs."
)
await msg.edit_text(text, parse_mode=ParseMode.HTML)
except Exception as e:
logger.error(f"Signals error: {e}")
await msg.edit_text(f"❌ Scan failed: {_esc(str(e))}", parse_mode=ParseMode.HTML)
async def cmd_pair(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
chat_id = update.effective_chat.id
state = self._state(chat_id)
args = context.args or []
if not args:
await update.message.reply_text(
"Usage: <b>/pair &lt;symbol&gt; [timeframe]</b>\n"
"Example: <code>/pair EURUSD H4</code>",
parse_mode=ParseMode.HTML,
)
return
raw = args[0].upper().replace("/", "").replace("_", "")
if not raw.endswith("=X"):
raw += "=X"
pair_state = ChatState()
pair_state.timeframe = self._parse_tf(args[1:]) or state.timeframe
display = SYMBOL_DISPLAY.get(raw, raw)
msg = await update.message.reply_text(f"🔄 Analyzing {_esc(display)}…")
try:
report = await self._analyze(self._run_pair, raw, pair_state)
if report is None:
await msg.edit_text(
f"❌ No data returned for <code>{_esc(raw)}</code>.",
parse_mode=ParseMode.HTML,
)
return
messages = _format_pair_detail(report)
await msg.edit_text(messages[0], parse_mode=ParseMode.HTML)
for m in messages[1:]:
await update.message.reply_text(m, parse_mode=ParseMode.HTML)
except Exception as e:
logger.error(f"Pair analysis error for {raw}: {e}")
await msg.edit_text(f"❌ Analysis failed: {_esc(str(e))}", parse_mode=ParseMode.HTML)
async def cmd_all(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
chat_id = update.effective_chat.id
state = self._state(chat_id)
all_state = ChatState()
all_state.timeframe = self._parse_tf(context.args or []) or state.timeframe
all_state.pairs = list(MAJOR_PAIRS) + list(CROSS_PAIRS)
msg = await update.message.reply_text("🔄 Scanning all pairs (major + cross)…")
try:
reports = await self._analyze(self._run_scan, all_state)
text = _format_scan(reports, all_state.timeframe, title="FULL PAIR SCAN")
await msg.edit_text(text, parse_mode=ParseMode.HTML)
except Exception as e:
logger.error(f"All-pairs scan error: {e}")
await msg.edit_text(f"❌ Scan failed: {_esc(str(e))}", parse_mode=ParseMode.HTML)
async def cmd_watch(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
chat_id = update.effective_chat.id
state = self._state(chat_id)
interval_minutes = WATCH_DEFAULT
if context.args:
try:
interval_minutes = int(context.args[0])
interval_minutes = max(WATCH_MIN_INTERVAL, min(WATCH_MAX_INTERVAL, interval_minutes))
except ValueError:
pass
# Remove any existing watch job for this chat
for job in context.job_queue.get_jobs_by_name(f"watch_{chat_id}"):
job.schedule_removal()
state.watch_enabled = True
context.job_queue.run_repeating(
self._watch_job,
interval=interval_minutes * 60,
first=5,
chat_id=chat_id,
name=f"watch_{chat_id}",
)
await update.message.reply_text(
f"✅ Watch alerts enabled — scanning every <b>{interval_minutes} min</b>.\n"
"Alerts fire when grade B+ signals appear. Use /unwatch to stop.",
parse_mode=ParseMode.HTML,
)
async def cmd_unwatch(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
chat_id = update.effective_chat.id
self._state(chat_id).watch_enabled = False
for job in context.job_queue.get_jobs_by_name(f"watch_{chat_id}"):
job.schedule_removal()
await update.message.reply_text("❌ Watch alerts disabled.")
# ── Watch job ──────────────────────────────────────────────────
async def _watch_job(self, context: CallbackContext):
chat_id = context.job.chat_id
state = self._state(chat_id)
if not state.watch_enabled:
return
try:
reports = await self._analyze(self._run_scan, state)
actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B")]
if actionable:
text = (
"🔔 <b>SIGNAL ALERT</b>\n\n"
+ _format_scan(actionable, state.timeframe, title="ACTIONABLE SIGNALS")
)
else:
now = datetime.now(timezone.utc).strftime("%H:%M UTC")
text = f"📊 <b>Watch scan {now}</b> — No grade B+ signals detected."
await context.bot.send_message(chat_id, text, parse_mode=ParseMode.HTML)
except Exception as e:
logger.error(f"Watch job error for chat {chat_id}: {e}")
# ── Application builder ────────────────────────────────────────
def build_application(self) -> Application:
token = os.environ.get("TELEGRAM_BOT_TOKEN")
if not token:
raise ValueError(
"TELEGRAM_BOT_TOKEN environment variable is not set.\n"
"Get a token from @BotFather on Telegram, then:\n"
" export TELEGRAM_BOT_TOKEN='your_token_here'"
)
app = (
Application.builder()
.token(token)
.build()
)
app.add_handler(CommandHandler("start", self.cmd_start))
app.add_handler(CommandHandler("help", self.cmd_help))
app.add_handler(CommandHandler("status", self.cmd_status))
app.add_handler(CommandHandler("tf", self.cmd_tf))
app.add_handler(CommandHandler("scan", self.cmd_scan))
app.add_handler(CommandHandler("signals", self.cmd_signals))
app.add_handler(CommandHandler("pair", self.cmd_pair))
app.add_handler(CommandHandler("all", self.cmd_all))
app.add_handler(CommandHandler("watch", self.cmd_watch))
app.add_handler(CommandHandler("unwatch", self.cmd_unwatch))
return app
# ══════════════════════════════════════════════════════════════════
def main():
logging.basicConfig(
level=logging.WARNING,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
bot = ForexTelegramBot()
app = bot.build_application()
print("🤖 Forex Signal Bot started. Press Ctrl+C to stop.")
app.run_polling(drop_pending_updates=True)
if __name__ == "__main__":
main()