Remove TF param — analyze all timeframes automatically

- /signal EURUSD sends one card per timeframe (M15, H1, H4, D1) concurrently
- /scan analyzes all pairs across all TFs, shows best-grade card per pair (B+ only)
- All TFs run in parallel via ThreadPoolExecutor (one worker per TF)
- Remove /tf command and per-chat state — no longer needed
- Executor pool sized to len(SCAN_TIMEFRAMES) for true concurrency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Harwinder
2026-05-04 17:27:15 -04:00
parent 7bde28c524
commit 0ad741670f
+124 -154
View File
@@ -3,15 +3,16 @@
Forex Signal Intelligence Platform - Telegram Bot Forex Signal Intelligence Platform - Telegram Bot
================================================== ==================================================
Trade alert frontend for the signal engine. Trade alert frontend for the signal engine.
Analyzes all timeframes automatically — no TF parameter needed.
Setup: Setup:
set TELEGRAM_BOT_TOKEN=your_token_here set TELEGRAM_BOT_TOKEN=your_token_here (Windows CMD)
export TELEGRAM_BOT_TOKEN=your_token_here (Linux/Mac)
python telegram_bot.py python telegram_bot.py
Commands: Commands:
/signal EURUSD [TF] — Signal card for one pair /signal EURUSD — Signal cards for one pair across all timeframes
/scan [TF] — Scan all pairs, show Grade B+ alerts /scan — Scan all pairs, show Grade B+ alerts
/tf H1 — Set default timeframe
/help — Command guide /help — Command guide
""" """
@@ -21,44 +22,44 @@ import logging
import os import os
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, List, Optional from typing import Dict, List, Optional, Tuple
from telegram import BotCommand, Update from telegram import BotCommand, Update
from telegram.constants import ParseMode from telegram.constants import ParseMode
from telegram.ext import Application, CommandHandler, ContextTypes from telegram.ext import Application, CommandHandler, ContextTypes
from config import ( from config import (
MAJOR_PAIRS, SYMBOL_DISPLAY, MAJOR_PAIRS, CROSS_PAIRS, SYMBOL_DISPLAY,
PlatformConfig, SignalStrength, Timeframe, PlatformConfig, SignalStrength, Timeframe,
) )
from signal_aggregator import SignalAggregator, SignalReport from signal_aggregator import SignalAggregator, SignalReport
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── Timeframe maps ────────────────────────────────────────────────── # ── Timeframes run on every analysis ───────────────────────────────
# Each TF is run as the primary; its confirmation TFs are auto-selected.
SCAN_TIMEFRAMES = ["M15", "H1", "H4", "D1"]
TF_MAP = { _TF_MAP = {
"M5": Timeframe.M5, "M5": Timeframe.M5, "M15": Timeframe.M15,
"M15": Timeframe.M15, "M30": Timeframe.M30, "H1": Timeframe.H1,
"M30": Timeframe.M30, "H4": Timeframe.H4, "D1": Timeframe.D1,
"H1": Timeframe.H1,
"H4": Timeframe.H4,
"D1": Timeframe.D1,
} }
TF_CONFIRMATIONS = { _TF_CONFIRMATIONS = {
"M5": [Timeframe.M1, Timeframe.M15],
"M15": [Timeframe.M5, Timeframe.H1], "M15": [Timeframe.M5, Timeframe.H1],
"M30": [Timeframe.M15, Timeframe.H1],
"H1": [Timeframe.M15, Timeframe.H4], "H1": [Timeframe.M15, Timeframe.H4],
"H4": [Timeframe.H1, Timeframe.D1], "H4": [Timeframe.H1, Timeframe.D1],
"D1": [Timeframe.H4, Timeframe.W1], "D1": [Timeframe.H4, Timeframe.W1],
} }
_SUPPORTED_PAIRS = set(MAJOR_PAIRS + CROSS_PAIRS)
_GRADE_RANK = {"A+": 0, "A": 1, "B": 2, "C": 3, "D": 4, "F": 5}
# ── Formatting helpers ────────────────────────────────────────────── # ── Formatting helpers ──────────────────────────────────────────────
def _grade_dot(quality: str) -> str: def _grade_dot(quality: str) -> str:
"""Colored dot matching confidence grade per spec."""
if quality in ("A+", "A"): if quality in ("A+", "A"):
return "🟢" return "🟢"
if quality == "B": if quality == "B":
@@ -77,22 +78,15 @@ def _signal_action(signal: SignalStrength) -> str:
def _indicator_condition(ind) -> str: def _indicator_condition(ind) -> str:
"""
Extract a short readable condition from the indicator description.
Prefer text after ' — ' (e.g. 'RSI 68.2 — Overbought' → 'Overbought').
Fall back to the signal enum name.
"""
desc = ind.description desc = ind.description
if " — " in desc: if " — " in desc:
return desc.split(" — ", 1)[1].strip() return desc.split(" — ", 1)[1].strip()
if " | " in desc: if " | " in desc:
# e.g. 'MACD bullish crossover | Hist: 0.00008' — take first clause
return desc.split(" | ", 1)[0].strip() return desc.split(" | ", 1)[0].strip()
return ind.signal.name.replace("_", " ").title() return ind.signal.name.replace("_", " ").title()
def _fmt_float(val: float) -> str: def _fmt_float(val: float) -> str:
"""Human-readable float with appropriate precision."""
if val == 0.0: if val == 0.0:
return "0" return "0"
av = abs(val) av = abs(val)
@@ -106,33 +100,11 @@ def _fmt_float(val: float) -> str:
def _h(text: str) -> str: def _h(text: str) -> str:
"""HTML-escape a string for safe use inside HTML parse mode."""
return html.escape(str(text)) return html.escape(str(text))
def _build_alert(report: SignalReport, timeframe: str) -> str: def _build_alert(report: SignalReport, timeframe: str) -> str:
""" """Build one trade alert card (HTML) for a given report + timeframe."""
Build a single trade alert card using the required visual template.
Returns an HTML-formatted string ready for Telegram.
Template:
{Dot} **TRADE ALERT: {ACTION} {PAIR}**
Grade / confidence / timeframe subtitle
**Entry:** {price}
**Take Profit:** {price} (+pips)
**Stop Loss:** {price} (-pips)
**Risk:Reward:** 1:2.0
**Technical Indicators:**
• {Name}: {Value} ({Condition})
...
Multi-Timeframe block
Warnings / events / news
Market context line
Disclaimer
"""
dot = _grade_dot(report.signal_quality) dot = _grade_dot(report.signal_quality)
action = _signal_action(report.final_signal) action = _signal_action(report.final_signal)
pair = _h(report.display_name) pair = _h(report.display_name)
@@ -146,7 +118,7 @@ def _build_alert(report: SignalReport, timeframe: str) -> str:
lines.append(f"<i>Grade: {grade} | Confidence: {conf:.0f}% | {timeframe}</i>") lines.append(f"<i>Grade: {grade} | Confidence: {conf:.0f}% | {timeframe}</i>")
lines.append("") lines.append("")
# ── Trade setup (only when grade B or above) ───────────────────── # ── Trade setup (Grade B and above only) ─────────────────────────
if report.trade_setup: if report.trade_setup:
ts = report.trade_setup ts = report.trade_setup
lines.append(f"<b>Entry:</b> {ts.entry_price:.5f}") lines.append(f"<b>Entry:</b> {ts.entry_price:.5f}")
@@ -162,7 +134,6 @@ def _build_alert(report: SignalReport, timeframe: str) -> str:
) )
lines.append(f"<b>Risk:Reward:</b> 1:{ts.risk_reward:.1f}") lines.append(f"<b>Risk:Reward:</b> 1:{ts.risk_reward:.1f}")
else: else:
# No actionable setup — show price and recommendation
lines.append(f"<b>Current Price:</b> {report.current_price:.5f}") lines.append(f"<b>Current Price:</b> {report.current_price:.5f}")
lines.append(f"<b>Analysis:</b> {_h(report.action_recommendation)}") lines.append(f"<b>Analysis:</b> {_h(report.action_recommendation)}")
@@ -183,7 +154,7 @@ def _build_alert(report: SignalReport, timeframe: str) -> str:
if report.mtf_signals: if report.mtf_signals:
lines.append("") lines.append("")
agree_str = "✅ Aligned" if report.mtf_agreement else "⚠️ Divergent" agree_str = "✅ Aligned" if report.mtf_agreement else "⚠️ Divergent"
lines.append(f"<b>Multi-Timeframe ({agree_str}):</b>") lines.append(f"<b>Confirmation TFs ({agree_str}):</b>")
for tf, sig in report.mtf_signals.items(): for tf, sig in report.mtf_signals.items():
score = report.mtf_scores.get(tf, 0) score = report.mtf_scores.get(tf, 0)
sig_name = _h(sig.name.replace("_", " ")) sig_name = _h(sig.name.replace("_", " "))
@@ -214,53 +185,50 @@ def _build_alert(report: SignalReport, timeframe: str) -> str:
return "\n".join(lines) return "\n".join(lines)
# ── Per-chat state ──────────────────────────────────────────────────
class ChatState:
def __init__(self):
self.timeframe: str = "H1"
# ══════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════
# Bot # Bot
# ══════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════
class ForexTelegramBot: class ForexTelegramBot:
def __init__(self): def __init__(self):
self._states: Dict[int, ChatState] = {} # One worker per timeframe so all TFs run concurrently
self._executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="forex") self._executor = ThreadPoolExecutor(
max_workers=len(SCAN_TIMEFRAMES), thread_name_prefix="forex"
)
# ── Helpers ─────────────────────────────────────────────────────── # ── Engine helpers ────────────────────────────────────────────────
def _state(self, chat_id: int) -> ChatState: def _build_config(self, tf: str, pairs: List[str]) -> PlatformConfig:
if chat_id not in self._states:
self._states[chat_id] = ChatState()
return self._states[chat_id]
def _build_config(self, tf: str, pairs: Optional[List[str]] = None) -> PlatformConfig:
cfg = PlatformConfig() cfg = PlatformConfig()
cfg.primary_timeframe = TF_MAP[tf] cfg.primary_timeframe = _TF_MAP[tf]
cfg.confirmation_timeframes = TF_CONFIRMATIONS.get(tf, [Timeframe.M15, Timeframe.H4]) cfg.confirmation_timeframes = _TF_CONFIRMATIONS.get(tf, [Timeframe.M15, Timeframe.H4])
cfg.watch_pairs = list(pairs or MAJOR_PAIRS) cfg.watch_pairs = list(pairs)
return cfg return cfg
def _run_scan(self, tf: str, pairs: List[str]) -> List[SignalReport]: def _run_scan(self, tf: str, pairs: List[str]) -> List[SignalReport]:
return SignalAggregator(self._build_config(tf, pairs)).analyze_all_pairs() return SignalAggregator(self._build_config(tf, pairs)).analyze_all_pairs()
async def _analyze(self, func, *args): async def _analyze_all_tfs(
self, pairs: List[str]
) -> Dict[str, List[SignalReport]]:
"""Run all SCAN_TIMEFRAMES concurrently, return {tf: [reports]}."""
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
return await loop.run_in_executor(self._executor, func, *args) tasks = {
tf: loop.run_in_executor(self._executor, self._run_scan, tf, list(pairs))
@staticmethod for tf in SCAN_TIMEFRAMES
def _parse_tf(args: List[str]) -> Optional[str]: }
for a in args: results: Dict[str, List[SignalReport]] = {}
if a.upper() in TF_MAP: for tf, task in tasks.items():
return a.upper() try:
return None results[tf] = await task
except Exception as e:
logger.error(f"TF {tf} scan failed: {e}", exc_info=True)
results[tf] = []
return results
@staticmethod @staticmethod
async def _send_html(update: Update, text: str): async def _send_html(update: Update, text: str):
"""Send HTML message, splitting at newlines if over Telegram's 4096-char limit.""" """Send HTML, splitting into chunks if over 4096 chars."""
LIMIT = 4000 LIMIT = 4000
chunks: List[str] = [] chunks: List[str] = []
while text: while text:
@@ -281,60 +249,33 @@ class ForexTelegramBot:
await self.cmd_help(update, context) await self.cmd_help(update, context)
async def cmd_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def cmd_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
state = self._state(update.effective_chat.id) tfs = " · ".join(SCAN_TIMEFRAMES)
text = ( text = (
"🌐 <b>Forex Signal Intelligence Platform</b>\n\n" "🌐 <b>Forex Signal Intelligence Platform</b>\n\n"
"<b>Commands:</b>\n" "<b>Commands:</b>\n"
" /signal EURUSD — Signal card for a pair\n" " /signal EURUSD — Signal cards for all timeframes\n"
" /signal EURUSD H4 — With specific timeframe\n" " /scan — Scan all pairs, Grade B+ alerts\n"
" /scan — Scan all pairs (Grade B+ alerts only)\n"
" /scan H4 — Scan at a specific timeframe\n"
" /tf H1 — Set your default timeframe\n"
" /help — This message\n\n" " /help — This message\n\n"
"<b>Timeframes:</b>\n" f"<b>Timeframes analyzed automatically:</b> <code>{tfs}</code>\n\n"
" <code>M5 </code> — 5 minutes\n"
" <code>M15</code> — 15 minutes\n"
" <code>M30</code> — 30 minutes\n"
" <code>H1 </code> — 1 hour (default, intraday)\n"
" <code>H4 </code> — 4 hours (swing trading)\n"
" <code>D1 </code> — Daily (position trading)\n\n"
"<b>Signal Grades:</b>\n" "<b>Signal Grades:</b>\n"
" 🟢 A / A+ — High confidence, consider entry\n" " 🟢 A / A+ — High confidence, consider entry\n"
" 🟡 B — Decent setup, wait for confirmation\n" " 🟡 B — Decent setup, wait for confirmation\n"
" 🔴 C / D — Low confidence, watch only\n\n" " 🔴 C / D — Low confidence, watch only\n\n"
f"Your default timeframe: <b>{state.timeframe}</b>\n\n"
"<i>⚠️ Educational use only. Not financial advice.</i>" "<i>⚠️ Educational use only. Not financial advice.</i>"
) )
await update.message.reply_text(text, parse_mode=ParseMode.HTML) 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_signal(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def cmd_signal(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
""" """
/signal EURUSD [TF] /signal EURUSD
Analyze a single pair and return the full trade alert card. Runs the pair through all timeframes concurrently and sends
one trade alert card per timeframe.
""" """
chat_id = update.effective_chat.id
state = self._state(chat_id)
args = context.args or [] args = context.args or []
if not args: if not args:
await update.message.reply_text( await update.message.reply_text(
"Usage: <b>/signal &lt;pair&gt; [timeframe]</b>\n" "Usage: <b>/signal &lt;pair&gt;</b>\n"
"Example: <code>/signal EURUSD H4</code>", "Example: <code>/signal EURUSD</code>",
parse_mode=ParseMode.HTML, parse_mode=ParseMode.HTML,
) )
return return
@@ -342,20 +283,38 @@ class ForexTelegramBot:
raw = args[0].upper().replace("/", "").replace("_", "") raw = args[0].upper().replace("/", "").replace("_", "")
if not raw.endswith("=X"): if not raw.endswith("=X"):
raw += "=X" raw += "=X"
tf = self._parse_tf(args[1:]) or state.timeframe
display = SYMBOL_DISPLAY.get(raw, raw.replace("=X", "")) display = SYMBOL_DISPLAY.get(raw, raw.replace("=X", ""))
tfs_str = " · ".join(SCAN_TIMEFRAMES)
msg = await update.message.reply_text(f"🔄 Analyzing {_h(display)} ({tf})…") synthetic_warning = ""
if raw not in _SUPPORTED_PAIRS:
synthetic_warning = (
"\n\n⚠️ <b>Synthetic data warning:</b> "
f"{_h(display)} is not on the Twelve Data free tier. "
"Prices and indicators are <b>simulated</b> — not live market data."
)
msg = await update.message.reply_text(
f"🔄 Analyzing {_h(display)} across {_h(tfs_str)}…"
)
try: try:
reports = await self._analyze(self._run_scan, tf, [raw]) results_by_tf = await self._analyze_all_tfs([raw])
if not reports:
await msg.edit_text(f"❌ No data returned for {_h(display)}")
return
r = reports[0]
alert = _build_alert(r, tf)
await msg.delete() await msg.delete()
sent = 0
for tf in SCAN_TIMEFRAMES:
reports = results_by_tf.get(tf, [])
if not reports:
continue
alert = _build_alert(reports[0], tf) + synthetic_warning
await self._send_html(update, alert) await self._send_html(update, alert)
sent += 1
if sent == 0:
await update.message.reply_text(
f"❌ No data returned for {_h(display)}"
)
except Exception as e: except Exception as e:
logger.error(f"Signal error for {raw}: {e}", exc_info=True) logger.error(f"Signal error for {raw}: {e}", exc_info=True)
@@ -363,51 +322,64 @@ class ForexTelegramBot:
async def cmd_scan(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def cmd_scan(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
""" """
/scan [TF] /scan
Analyze all major pairs and send a trade alert card for every Analyzes all major pairs across all timeframes concurrently.
signal rated Grade B or above. For each pair, picks the highest-grade result across all TFs
and sends a card for Grade B and above.
""" """
chat_id = update.effective_chat.id
state = self._state(chat_id)
tf = self._parse_tf(context.args or []) or state.timeframe
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
msg = await update.message.reply_text(f"🔄 Scanning all pairs ({tf})…") tfs_str = " · ".join(SCAN_TIMEFRAMES)
msg = await update.message.reply_text(
f"🔄 Scanning all pairs across {_h(tfs_str)}…"
)
try: try:
reports = await self._analyze(self._run_scan, tf, list(MAJOR_PAIRS)) results_by_tf = await self._analyze_all_tfs(list(MAJOR_PAIRS))
actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B")] # For each pair pick the best grade across all TFs
# best_per_pair: {symbol: (tf, report)}
best_per_pair: Dict[str, Tuple[str, SignalReport]] = {}
for tf, reports in results_by_tf.items():
for r in reports:
current = best_per_pair.get(r.symbol)
if current is None or (
_GRADE_RANK.get(r.signal_quality, 99)
< _GRADE_RANK.get(current[1].signal_quality, 99)
):
best_per_pair[r.symbol] = (tf, r)
# Filter to B+ and sort best-first
actionable = [
(tf, r)
for tf, r in best_per_pair.values()
if r.signal_quality in ("A+", "A", "B")
]
actionable.sort(key=lambda x: _GRADE_RANK.get(x[1].signal_quality, 99))
if not actionable: if not actionable:
await msg.edit_text( await msg.edit_text(
f"🔍 <b>Scan complete</b> — {now_str}\n\n" f"🔍 <b>Scan complete</b> — {now_str}\n\n"
"No Grade B+ signals found at this time.\n" "No Grade B+ signals found across any timeframe right now.",
"Try a different timeframe with <code>/scan H4</code>.",
parse_mode=ParseMode.HTML, parse_mode=ParseMode.HTML,
) )
return return
# Summary header # Tally grades
grades = {"A+": 0, "A": 0, "B": 0} grade_counts: Dict[str, int] = {}
for r in actionable: for _, r in actionable:
grades[r.signal_quality] = grades.get(r.signal_quality, 0) + 1 grade_counts[r.signal_quality] = grade_counts.get(r.signal_quality, 0) + 1
grade_str = " ".join(f"{g}: {n}" for g, n in grade_counts.items())
grade_str = " ".join(
f"{g}: {count}" for g, count in grades.items() if count
)
summary = ( summary = (
f"📊 <b>Scan complete</b> — {now_str}\n" f"📊 <b>Scan complete</b> — {now_str}\n"
f"Timeframe: <b>{tf}</b>\n" f"Timeframes: <code>{_h(tfs_str)}</code>\n"
f"Grade B+ signals: <b>{len(actionable)}</b> ({grade_str})\n\n" f"Grade B+ signals: <b>{len(actionable)}</b> ({grade_str})\n\n"
"Trade alerts follow ↓" "Best signal per pair ↓"
) )
await msg.edit_text(summary, parse_mode=ParseMode.HTML) await msg.edit_text(summary, parse_mode=ParseMode.HTML)
# One card per actionable signal for tf, r in actionable:
for r in actionable: await self._send_html(update, _build_alert(r, tf))
alert = _build_alert(r, tf)
await self._send_html(update, alert)
except Exception as e: except Exception as e:
logger.error(f"Scan error: {e}", exc_info=True) logger.error(f"Scan error: {e}", exc_info=True)
@@ -421,15 +393,14 @@ class ForexTelegramBot:
raise ValueError( raise ValueError(
"TELEGRAM_BOT_TOKEN environment variable is not set.\n" "TELEGRAM_BOT_TOKEN environment variable is not set.\n"
"Get a token from @BotFather on Telegram, then:\n" "Get a token from @BotFather on Telegram, then:\n"
" Windows: set TELEGRAM_BOT_TOKEN=your_token\n" " Windows CMD: set TELEGRAM_BOT_TOKEN=your_token\n"
" Linux/Mac: export TELEGRAM_BOT_TOKEN=your_token" " Linux/Mac: export TELEGRAM_BOT_TOKEN=your_token"
) )
async def _register_commands(app: Application) -> None: async def _register_commands(app: Application) -> None:
await app.bot.set_my_commands([ await app.bot.set_my_commands([
BotCommand("signal", "Trade alert for one pair — /signal EURUSD"), BotCommand("signal", "Signal cards for one pair — /signal EURUSD"),
BotCommand("scan", "Scan all pairs, Grade B+ alerts — /scan H4"), BotCommand("scan", "Scan all pairs, best Grade B+ per pair"),
BotCommand("tf", "Set default timeframe — /tf H4"),
BotCommand("help", "Commands and grade guide"), BotCommand("help", "Commands and grade guide"),
]) ])
@@ -442,7 +413,6 @@ class ForexTelegramBot:
app.add_handler(CommandHandler("start", self.cmd_start)) app.add_handler(CommandHandler("start", self.cmd_start))
app.add_handler(CommandHandler("help", self.cmd_help)) app.add_handler(CommandHandler("help", self.cmd_help))
app.add_handler(CommandHandler("tf", self.cmd_tf))
app.add_handler(CommandHandler("signal", self.cmd_signal)) app.add_handler(CommandHandler("signal", self.cmd_signal))
app.add_handler(CommandHandler("scan", self.cmd_scan)) app.add_handler(CommandHandler("scan", self.cmd_scan))