From 0ad741670f270d7eadf139476980575f47e687cd Mon Sep 17 00:00:00 2001 From: Harwinder Date: Mon, 4 May 2026 17:27:15 -0400 Subject: [PATCH] =?UTF-8?q?Remove=20TF=20param=20=E2=80=94=20analyze=20all?= =?UTF-8?q?=20timeframes=20automatically?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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 --- telegram_bot.py | 288 ++++++++++++++++++++++-------------------------- 1 file changed, 129 insertions(+), 159 deletions(-) diff --git a/telegram_bot.py b/telegram_bot.py index 9b32d25..1a784d8 100644 --- a/telegram_bot.py +++ b/telegram_bot.py @@ -3,16 +3,17 @@ Forex Signal Intelligence Platform - Telegram Bot ================================================== Trade alert frontend for the signal engine. +Analyzes all timeframes automatically — no TF parameter needed. 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 Commands: - /signal EURUSD [TF] — Signal card for one pair - /scan [TF] — Scan all pairs, show Grade B+ alerts - /tf H1 — Set default timeframe - /help — Command guide + /signal EURUSD — Signal cards for one pair across all timeframes + /scan — Scan all pairs, show Grade B+ alerts + /help — Command guide """ import asyncio @@ -21,44 +22,44 @@ import logging import os from concurrent.futures import ThreadPoolExecutor 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.constants import ParseMode from telegram.ext import Application, CommandHandler, ContextTypes from config import ( - MAJOR_PAIRS, SYMBOL_DISPLAY, + MAJOR_PAIRS, CROSS_PAIRS, SYMBOL_DISPLAY, PlatformConfig, SignalStrength, Timeframe, ) from signal_aggregator import SignalAggregator, SignalReport 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 = { - "M5": Timeframe.M5, - "M15": Timeframe.M15, - "M30": Timeframe.M30, - "H1": Timeframe.H1, - "H4": Timeframe.H4, - "D1": Timeframe.D1, +_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], +_TF_CONFIRMATIONS = { "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], } +_SUPPORTED_PAIRS = set(MAJOR_PAIRS + CROSS_PAIRS) + +_GRADE_RANK = {"A+": 0, "A": 1, "B": 2, "C": 3, "D": 4, "F": 5} + # ── Formatting helpers ────────────────────────────────────────────── def _grade_dot(quality: str) -> str: - """Colored dot matching confidence grade per spec.""" if quality in ("A+", "A"): return "🟢" if quality == "B": @@ -77,22 +78,15 @@ def _signal_action(signal: SignalStrength) -> 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 if " — " in desc: return desc.split(" — ", 1)[1].strip() if " | " in desc: - # e.g. 'MACD bullish crossover | Hist: 0.00008' — take first clause return desc.split(" | ", 1)[0].strip() return ind.signal.name.replace("_", " ").title() def _fmt_float(val: float) -> str: - """Human-readable float with appropriate precision.""" if val == 0.0: return "0" av = abs(val) @@ -106,33 +100,11 @@ def _fmt_float(val: float) -> str: def _h(text: str) -> str: - """HTML-escape a string for safe use inside HTML parse mode.""" return html.escape(str(text)) def _build_alert(report: SignalReport, timeframe: str) -> str: - """ - 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 - """ + """Build one trade alert card (HTML) for a given report + timeframe.""" dot = _grade_dot(report.signal_quality) action = _signal_action(report.final_signal) pair = _h(report.display_name) @@ -146,7 +118,7 @@ def _build_alert(report: SignalReport, timeframe: str) -> str: lines.append(f"Grade: {grade} | Confidence: {conf:.0f}% | {timeframe}") lines.append("") - # ── Trade setup (only when grade B or above) ───────────────────── + # ── Trade setup (Grade B and above only) ───────────────────────── if report.trade_setup: ts = report.trade_setup lines.append(f"Entry: {ts.entry_price:.5f}") @@ -162,7 +134,6 @@ def _build_alert(report: SignalReport, timeframe: str) -> str: ) lines.append(f"Risk:Reward: 1:{ts.risk_reward:.1f}") else: - # No actionable setup — show price and recommendation lines.append(f"Current Price: {report.current_price:.5f}") lines.append(f"Analysis: {_h(report.action_recommendation)}") @@ -183,7 +154,7 @@ def _build_alert(report: SignalReport, timeframe: str) -> str: if report.mtf_signals: lines.append("") agree_str = "✅ Aligned" if report.mtf_agreement else "⚠️ Divergent" - lines.append(f"Multi-Timeframe ({agree_str}):") + lines.append(f"Confirmation TFs ({agree_str}):") for tf, sig in report.mtf_signals.items(): score = report.mtf_scores.get(tf, 0) sig_name = _h(sig.name.replace("_", " ")) @@ -214,53 +185,50 @@ def _build_alert(report: SignalReport, timeframe: str) -> str: return "\n".join(lines) -# ── Per-chat state ────────────────────────────────────────────────── - -class ChatState: - def __init__(self): - self.timeframe: str = "H1" - - # ══════════════════════════════════════════════════════════════════════ # Bot # ══════════════════════════════════════════════════════════════════════ class ForexTelegramBot: def __init__(self): - self._states: Dict[int, ChatState] = {} - self._executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="forex") + # One worker per timeframe so all TFs run concurrently + self._executor = ThreadPoolExecutor( + max_workers=len(SCAN_TIMEFRAMES), thread_name_prefix="forex" + ) - # ── Helpers ─────────────────────────────────────────────────────── + # ── Engine helpers ──────────────────────────────────────────────── - def _state(self, chat_id: int) -> ChatState: - 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: + def _build_config(self, tf: str, pairs: List[str]) -> PlatformConfig: cfg = PlatformConfig() - cfg.primary_timeframe = TF_MAP[tf] - cfg.confirmation_timeframes = TF_CONFIRMATIONS.get(tf, [Timeframe.M15, Timeframe.H4]) - cfg.watch_pairs = list(pairs or MAJOR_PAIRS) + cfg.primary_timeframe = _TF_MAP[tf] + cfg.confirmation_timeframes = _TF_CONFIRMATIONS.get(tf, [Timeframe.M15, Timeframe.H4]) + cfg.watch_pairs = list(pairs) return cfg def _run_scan(self, tf: str, pairs: List[str]) -> List[SignalReport]: 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() - return await loop.run_in_executor(self._executor, func, *args) - - @staticmethod - def _parse_tf(args: List[str]) -> Optional[str]: - for a in args: - if a.upper() in TF_MAP: - return a.upper() - return None + tasks = { + tf: loop.run_in_executor(self._executor, self._run_scan, tf, list(pairs)) + for tf in SCAN_TIMEFRAMES + } + results: Dict[str, List[SignalReport]] = {} + for tf, task in tasks.items(): + try: + results[tf] = await task + except Exception as e: + logger.error(f"TF {tf} scan failed: {e}", exc_info=True) + results[tf] = [] + return results @staticmethod 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 chunks: List[str] = [] while text: @@ -281,60 +249,33 @@ class ForexTelegramBot: await self.cmd_help(update, context) async def cmd_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE): - state = self._state(update.effective_chat.id) + tfs = " · ".join(SCAN_TIMEFRAMES) text = ( "🌐 Forex Signal Intelligence Platform\n\n" "Commands:\n" - " /signal EURUSD — Signal card for a pair\n" - " /signal EURUSD H4 — With specific timeframe\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" - "Timeframes:\n" - " M5 — 5 minutes\n" - " M15 — 15 minutes\n" - " M30 — 30 minutes\n" - " H1 — 1 hour (default, intraday)\n" - " H4 — 4 hours (swing trading)\n" - " D1 — Daily (position trading)\n\n" + " /signal EURUSD — Signal cards for all timeframes\n" + " /scan — Scan all pairs, Grade B+ alerts\n" + " /help — This message\n\n" + f"Timeframes analyzed automatically: {tfs}\n\n" "Signal Grades:\n" " 🟢 A / A+ — High confidence, consider entry\n" " 🟡 B — Decent setup, wait for confirmation\n" " 🔴 C / D — Low confidence, watch only\n\n" - f"Your default timeframe: {state.timeframe}\n\n" "⚠️ Educational use only. Not financial advice." ) 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: /tf <timeframe>\nOptions: {opts}", - parse_mode=ParseMode.HTML, - ) - return - self._state(update.effective_chat.id).timeframe = tf - await update.message.reply_text( - f"✅ Default timeframe set to {tf}", - parse_mode=ParseMode.HTML, - ) - async def cmd_signal(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """ - /signal EURUSD [TF] - Analyze a single pair and return the full trade alert card. + /signal EURUSD + 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: await update.message.reply_text( - "Usage: /signal <pair> [timeframe]\n" - "Example: /signal EURUSD H4", + "Usage: /signal <pair>\n" + "Example: /signal EURUSD", parse_mode=ParseMode.HTML, ) return @@ -342,20 +283,38 @@ class ForexTelegramBot: raw = args[0].upper().replace("/", "").replace("_", "") if not raw.endswith("=X"): raw += "=X" - tf = self._parse_tf(args[1:]) or state.timeframe 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⚠️ Synthetic data warning: " + f"{_h(display)} is not on the Twelve Data free tier. " + "Prices and indicators are simulated — not live market data." + ) + + msg = await update.message.reply_text( + f"🔄 Analyzing {_h(display)} across {_h(tfs_str)}…" + ) try: - reports = await self._analyze(self._run_scan, tf, [raw]) - if not reports: - await msg.edit_text(f"❌ No data returned for {_h(display)}") - return + results_by_tf = await self._analyze_all_tfs([raw]) - r = reports[0] - alert = _build_alert(r, tf) await msg.delete() - await self._send_html(update, alert) + + 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) + sent += 1 + + if sent == 0: + await update.message.reply_text( + f"❌ No data returned for {_h(display)}" + ) except Exception as e: 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): """ - /scan [TF] - Analyze all major pairs and send a trade alert card for every - signal rated Grade B or above. + /scan + Analyzes all major pairs across all timeframes concurrently. + 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") - 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: - 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: await msg.edit_text( f"🔍 Scan complete — {now_str}\n\n" - "No Grade B+ signals found at this time.\n" - "Try a different timeframe with /scan H4.", + "No Grade B+ signals found across any timeframe right now.", parse_mode=ParseMode.HTML, ) return - # Summary header - grades = {"A+": 0, "A": 0, "B": 0} - for r in actionable: - grades[r.signal_quality] = grades.get(r.signal_quality, 0) + 1 + # Tally grades + grade_counts: Dict[str, int] = {} + for _, r in actionable: + 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 = ( f"📊 Scan complete — {now_str}\n" - f"Timeframe: {tf}\n" + f"Timeframes: {_h(tfs_str)}\n" f"Grade B+ signals: {len(actionable)} ({grade_str})\n\n" - "Trade alerts follow ↓" + "Best signal per pair ↓" ) await msg.edit_text(summary, parse_mode=ParseMode.HTML) - # One card per actionable signal - for r in actionable: - alert = _build_alert(r, tf) - await self._send_html(update, alert) + for tf, r in actionable: + await self._send_html(update, _build_alert(r, tf)) except Exception as e: logger.error(f"Scan error: {e}", exc_info=True) @@ -421,15 +393,14 @@ class ForexTelegramBot: raise ValueError( "TELEGRAM_BOT_TOKEN environment variable is not set.\n" "Get a token from @BotFather on Telegram, then:\n" - " Windows: set TELEGRAM_BOT_TOKEN=your_token\n" - " Linux/Mac: export TELEGRAM_BOT_TOKEN=your_token" + " Windows CMD: set TELEGRAM_BOT_TOKEN=your_token\n" + " Linux/Mac: export TELEGRAM_BOT_TOKEN=your_token" ) async def _register_commands(app: Application) -> None: await app.bot.set_my_commands([ - BotCommand("signal", "Trade alert for one pair — /signal EURUSD"), - BotCommand("scan", "Scan all pairs, Grade B+ alerts — /scan H4"), - BotCommand("tf", "Set default timeframe — /tf H4"), + BotCommand("signal", "Signal cards for one pair — /signal EURUSD"), + BotCommand("scan", "Scan all pairs, best Grade B+ per pair"), BotCommand("help", "Commands and grade guide"), ]) @@ -442,7 +413,6 @@ class ForexTelegramBot: app.add_handler(CommandHandler("start", self.cmd_start)) 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("scan", self.cmd_scan))