#!/usr/bin/env python3 """ ForexBot2 — Telegram Bot ======================== Async Telegram interface with candle-boundary auto-scanner. Manual scan: XAU/USD + EUR/USD via inline buttons. Auto-scan: Starts automatically on boot if TELEGRAM_CHAT_ID is set. /autoscan toggles for additional chats or to stop. M5 fires at :00/:05/:10/… + 10s buffer (data only, no signals). M15 fires at :00/:15/:30/:45 + 10s buffer. H1 fires at :00 + 10s buffer. H4 fires at :00 every 4 hours + 10s buffer. Usage: python testing_telegram_bot.py TELEGRAM_TOKEN= TELEGRAM_CHAT_ID= python testing_telegram_bot.py """ import asyncio import logging import os import signal import sys import time from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone from zoneinfo import ZoneInfo from typing import Dict, List, Optional, Set from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.constants import ParseMode from telegram.ext import ( Application, CallbackQueryHandler, CommandHandler, ContextTypes, ) from config import ( PlatformConfig, SignalStrength, Timeframe, XAUUSD, EURUSD, PAIR_INFO, ) from data_provider import ForexDataProvider from signal_aggregator import SignalAggregator, SignalReport from pair_signals import produce_signal # ── Force UTF-8 on Windows ──────────────────────────────────────────── if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") logging.basicConfig( format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", level=logging.INFO, datefmt="%H:%M:%S", ) logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("httpcore").setLevel(logging.WARNING) logger = logging.getLogger("telegram_bot") TOKEN = os.environ.get("TELEGRAM_TOKEN", "8882954582:AAEOi2MzoMbJjozDMiRtFBHc2muix_MtaQQ") CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6025899022") # ── Pairs & buttons ─────────────────────────────────────────────────── SCAN_PAIRS = [XAUUSD, EURUSD] _LEVEL_KEYS = ( "swing_high_1", "swing_high_2", "swing_high_3", "swing_low_1", "swing_low_2", "swing_low_3", "ema_trend", "bb_upper", "bb_lower", "kc_upper", "kc_lower", ) # ── Live signal stats cache ────────────────────────────────────────── # symbol → { tf_label → SignalReport } _signal_stats: Dict[str, Dict[str, SignalReport]] = {} _stats_updated: Optional[float] = None SCAN_BUTTONS = [ {"cb": "scan_xauusd_m5", "symbol": XAUUSD, "tf": Timeframe.M5, "label": "XAU 5m"}, {"cb": "scan_xauusd_m15", "symbol": XAUUSD, "tf": Timeframe.M15, "label": "XAU 15m"}, {"cb": "scan_xauusd_h1", "symbol": XAUUSD, "tf": Timeframe.H1, "label": "XAU 1h"}, {"cb": "scan_xauusd_h4", "symbol": XAUUSD, "tf": Timeframe.H4, "label": "XAU 4h"}, {"cb": "scan_xauusd_all", "symbol": XAUUSD, "tf": None, "label": "XAU All"}, {"cb": "scan_eurusd_m5", "symbol": EURUSD, "tf": Timeframe.M5, "label": "EUR 5m"}, {"cb": "scan_eurusd_m15", "symbol": EURUSD, "tf": Timeframe.M15, "label": "EUR 15m"}, {"cb": "scan_eurusd_h1", "symbol": EURUSD, "tf": Timeframe.H1, "label": "EUR 1h"}, {"cb": "scan_eurusd_h4", "symbol": EURUSD, "tf": Timeframe.H4, "label": "EUR 4h"}, {"cb": "scan_eurusd_all", "symbol": EURUSD, "tf": None, "label": "EUR All"}, ] SCAN_LOOKUP = {b["cb"]: b for b in SCAN_BUTTONS} TF_CONFIRMATIONS = { Timeframe.M5: [Timeframe.M15, Timeframe.H1], Timeframe.M15: [Timeframe.M5, Timeframe.H1], Timeframe.H1: [Timeframe.M15, Timeframe.H4], Timeframe.H4: [Timeframe.H1, Timeframe.D1], } AUTOSCAN_CONFIRMATIONS = { Timeframe.M5: [Timeframe.M15, Timeframe.H1], Timeframe.M15: [Timeframe.M5, Timeframe.H1], Timeframe.H1: [Timeframe.M15, Timeframe.H4], Timeframe.H4: [Timeframe.H1, Timeframe.D1], } _executor = ThreadPoolExecutor(max_workers=4) # ── Markdown escaping ───────────────────────────────────────────────── def _escape_md(text: str) -> str: for ch in ("*", "_", "`", "[", "]"): text = text.replace(ch, "\\" + ch) return text def _fmt_price(price: float) -> str: return f"{price:.2f}" if price >= 10 else f"{price:.5f}" # ── Shared data provider (single cache across all scans) ───────────── _shared_dp: Optional[ForexDataProvider] = None def _get_shared_dp() -> ForexDataProvider: global _shared_dp if _shared_dp is None: _shared_dp = ForexDataProvider(PlatformConfig()) return _shared_dp # ── Signal formatting ───────────────────────────────────────────────── _SIGNAL_EMOJI = { SignalStrength.STRONG_BUY: "🟢", SignalStrength.BUY: "🟩", SignalStrength.NEUTRAL: "⬜", SignalStrength.SELL: "🟥", SignalStrength.STRONG_SELL: "🔴", } _QUALITY_EMOJI = { "A+": "🏆", "A": "🥇", "B": "🥈", "C": "🥉", "D": "⚠️", "F": "❌", } _SIGNAL_TAG = { SignalStrength.STRONG_BUY: "Strong Buy", SignalStrength.BUY: "Bullish", SignalStrength.NEUTRAL: "Neutral", SignalStrength.SELL: "Bearish", SignalStrength.STRONG_SELL: "Strong Sell", } def _signal_label(sig: SignalStrength) -> str: return sig.name.replace("_", " ") def _mtf_line(report: SignalReport) -> str: if not report.mtf_signals: return "" parts = [] for tf, sig in report.mtf_signals.items(): score = report.mtf_scores.get(tf, 0) parts.append(f"{tf}: {_signal_label(sig)} {score:+.1f}") return " | ".join(parts) def _indicator_line(name: str, ind) -> str: tag = _SIGNAL_TAG.get(ind.signal, "Neutral") short = ind.description.split(" | ")[0] if " | " in ind.description else ind.description return f"[{tag}] *{_escape_md(ind.name)}*: {_escape_md(short)}" def format_signal_message(report: SignalReport) -> str: sig = report.final_signal emoji = _SIGNAL_EMOJI.get(sig, "⬜") qe = _QUALITY_EMOJI.get(report.signal_quality, "") label = _signal_label(sig) mtf_tag = "✅ MTF aligned" if report.mtf_agreement else "❌ MTF diverged" lines: list[str] = [] now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") regime_str = _escape_md(report.market_regime.replace("_", " ").title()) vol_str = _escape_md(str(report.volatility_regime)) lines += [ f"🔍 *{report.display_name} Signal Analysis* ({report.primary_analysis.timeframe if report.primary_analysis else ''})", f"🕐 {now}", "", f"💰 Price: *{_fmt_price(report.current_price)}*", f"📊 Regime: {regime_str} | Vol: {vol_str}", "", "━━━━━━━━━━━━━━━━━━━━", f"{emoji} *{label}* {qe}{report.signal_quality} | Score: {report.final_score:+.1f} | {report.final_confidence:.0f}% conf", f"_{_escape_md(report.action_recommendation)}_", f"{mtf_tag}", ] mtf = _mtf_line(report) if mtf: lines += ["", f"📈 *MTF*: {mtf}"] if report.primary_analysis: lines += ["", "━━━━━━━━━━━━━━━━━━━━", "📋 *Indicators*"] for name, ind in report.primary_analysis.indicators.items(): if name.startswith("_") or ind.weight == 0: continue lines.append(_indicator_line(name, ind)) if report.warnings: lines.append("") for w in report.warnings: lines.append(f"⚠️ {_escape_md(w)}") if report.trade_setup and sig not in (SignalStrength.NEUTRAL,): ts = report.trade_setup dir_emoji = "📈" if ts.direction == "BUY" else "📉" lines += [ "", "━━━━━━━━━━━━━━━━━━━━", f"🚀 *TRADE SETUP*", f"{dir_emoji} Direction: *{ts.direction}*", f"▶ Entry: `{_fmt_price(ts.entry_price)}`", f"🛑 SL: `{_fmt_price(ts.stop_loss)}` ({ts.risk_pips:.1f} pips)", f"🎯 TP1: `{_fmt_price(ts.take_profit_1)}` ({ts.reward_pips:.1f} pips)", ] if ts.take_profit_2: lines.append(f"🎯 TP2: `{_fmt_price(ts.take_profit_2)}`") lines.append(f"⚖️ RR: 1:{ts.risk_reward:.1f}") lines += ["", "━━━━━━━━━━━━━━━━━━━━", "_Educational / research use only._"] return "\n".join(lines) # ── Auto-scan summary format ───────────────────────────────────────── def _format_scan_summary(reports: List[SignalReport], tf: Timeframe) -> str: now = datetime.now(timezone.utc).strftime("%H:%M UTC") lines = [ f"📊 *{tf.value} Auto-Scan* | {now}", "━━━━━━━━━━━━━━━━━━━━", ] for r in reports: emoji = _SIGNAL_EMOJI.get(r.final_signal, "⬜") sig = _signal_label(r.final_signal) mtf = "✅" if r.mtf_agreement else "❌" lines.append( f"{emoji} *{r.display_name}* {sig} " f"{_QUALITY_EMOJI.get(r.signal_quality, '')}{r.signal_quality} " f"{r.final_score:+.1f} {r.final_confidence:.0f}% {mtf}MTF" ) lines.append("━━━━━━━━━━━━━━━━━━━━") actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B", "C")] if actionable: names = ", ".join(r.display_name for r in actionable) lines.append(f"🔔 Actionable: {names}") else: lines.append("No actionable signals this cycle.") return "\n".join(lines) def _update_signal_stats(reports: List[SignalReport], tf: Timeframe) -> None: global _stats_updated for r in reports: if r.symbol not in _signal_stats: _signal_stats[r.symbol] = {} _signal_stats[r.symbol][tf.value] = r _stats_updated = time.time() _SHORT_SIGNAL = { SignalStrength.STRONG_BUY: "Buy", SignalStrength.BUY: "Buy", SignalStrength.NEUTRAL: "Neutral", SignalStrength.SELL: "Sell", SignalStrength.STRONG_SELL: "Sell", } _TF_DISPLAY = {"5min": "M5", "15min": "M15", "1h": "H1", "4h": "H4", "1d": "D1"} _DASH_TFS = [Timeframe.M15, Timeframe.H1, Timeframe.H4] def _format_stats_dashboard() -> str: if not _signal_stats: return "📊 *Live Dashboard*\n\n_Waiting for first scan…_" _ny_tz = ZoneInfo("America/New_York") now = datetime.now(_ny_tz).strftime("%H:%M:%S %Z") lines = [f"📊 *Live Dashboard* — {now}", ""] for symbol in SCAN_PAIRS: display = PAIR_INFO.get(symbol, {}).get("display", symbol) pair_data = _signal_stats.get(symbol, {}) latest = None for tf in reversed(_DASH_TFS): if tf.value in pair_data: latest = pair_data[tf.value] break price = latest.current_price if latest else 0 price_str = f"`{_fmt_price(price)}`" regime = latest.market_regime.replace("_", " ").title() if latest else "" tf_parts = [] for tf in _DASH_TFS: label = _TF_DISPLAY.get(tf.value, tf.value) r = pair_data.get(tf.value) if not r: tf_parts.append(f"{label}: ⬜—") continue emoji = _SIGNAL_EMOJI.get(r.final_signal, "⬜") sig = _SHORT_SIGNAL.get(r.final_signal, "Neutral") tf_parts.append(f"{label}: {emoji}{sig}") lines.append(f"*{display}* {price_str} _{_escape_md(regime)}_") lines.append(" | ".join(tf_parts)) if latest and latest.trade_setup and latest.final_signal not in (SignalStrength.NEUTRAL,): ts = latest.trade_setup d = "📈" if ts.direction == "BUY" else "📉" lines.append( f"{d} {ts.direction} `{_fmt_price(ts.entry_price)}` " f"SL `{_fmt_price(ts.stop_loss)}` TP `{_fmt_price(ts.take_profit_1)}` " f"RR 1:{ts.risk_reward:.1f}" ) lines.append("") lines.append("_🔄 live refresh_") return "\n".join(lines) # ── Live dashboard (edit-in-place) ─────────────────────────────────── _dashboard_msg_ids: Dict[int, int] = {} _dashboard_task: Optional[asyncio.Task] = None _last_dashboard_refresh: float = 0 _DASHBOARD_REFRESH_INTERVAL = 30 _dashboard_lock = asyncio.Lock() async def _send_dashboard(bot, chat_id: int) -> None: text = _format_stats_dashboard() try: msg = await bot.send_message(chat_id, text, parse_mode=ParseMode.MARKDOWN) _dashboard_msg_ids[chat_id] = msg.message_id except Exception as exc: logger.warning(f"Dashboard send failed for chat {chat_id}: {exc}") async def _refresh_dashboard(bot, force: bool = False) -> None: global _last_dashboard_refresh async with _dashboard_lock: now = time.time() if not force and (now - _last_dashboard_refresh) < _DASHBOARD_REFRESH_INTERVAL: return _last_dashboard_refresh = now text = _format_stats_dashboard() for chat_id in list(_autoscan_chats): msg_id = _dashboard_msg_ids.get(chat_id) if not msg_id: await _send_dashboard(bot, chat_id) continue try: await bot.edit_message_text( text, chat_id=chat_id, message_id=msg_id, parse_mode=ParseMode.MARKDOWN, ) except Exception as exc: if "message is not modified" not in str(exc).lower(): logger.warning(f"Dashboard edit failed for chat {chat_id}: {exc}") async def _dashboard_loop(bot) -> None: try: while _autoscan_chats: await asyncio.sleep(_DASHBOARD_REFRESH_INTERVAL) if not _autoscan_chats: break await _refresh_dashboard(bot) except asyncio.CancelledError: pass finally: logger.info("Dashboard refresh loop stopped") # ── Keyboard helper ─────────────────────────────────────────────────── def scan_keyboard() -> InlineKeyboardMarkup: rows = [] for pair in SCAN_PAIRS: pair_btns = [b for b in SCAN_BUTTONS if b["symbol"] == pair] row = [ InlineKeyboardButton(b["label"], callback_data=b["cb"]) for b in pair_btns ] rows.append(row) return InlineKeyboardMarkup(rows) # ── Signal runner (shared data provider for cache reuse) ────────────── def _run_signal_sync( symbol: str, timeframe: Timeframe, confirmation_tfs: Optional[List[Timeframe]] = None, ) -> SignalReport: if confirmation_tfs is None: confirmation_tfs = TF_CONFIRMATIONS.get( timeframe, [Timeframe.M15, Timeframe.H4] ) return produce_signal( symbol=symbol, primary_tf=timeframe, data_provider=_get_shared_dp(), confirmation_tfs=confirmation_tfs, ) async def run_signal( symbol: str, timeframe: Timeframe, confirmation_tfs: Optional[List[Timeframe]] = None, ) -> SignalReport: loop = asyncio.get_running_loop() return await loop.run_in_executor( _executor, _run_signal_sync, symbol, timeframe, confirmation_tfs ) # ── Candle-boundary scheduler ──────────────────────────────────────── _autoscan_chats: Set[int] = set() _autoscan_tasks: list[asyncio.Task] = [] def _seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float: """Seconds until the next candle close + buffer.""" now = datetime.now(timezone.utc) total_minutes = now.hour * 60 + now.minute elapsed_minutes = total_minutes % interval_minutes elapsed_seconds = elapsed_minutes * 60 + now.second + now.microsecond / 1e6 interval_seconds = interval_minutes * 60 remaining = interval_seconds - elapsed_seconds + buffer if remaining <= buffer: remaining += interval_seconds return remaining async def _scan_timeframe( bot, tf: Timeframe, confirmation_tfs: List[Timeframe], ) -> None: """Scan all pairs for one timeframe and broadcast results.""" tasks = [run_signal(symbol, tf, confirmation_tfs) for symbol in SCAN_PAIRS] results = await asyncio.gather(*tasks, return_exceptions=True) reports: List[SignalReport] = [] for i, result in enumerate(results): if isinstance(result, Exception): logger.exception(f"Auto-scan failed for {SCAN_PAIRS[i]} {tf.value}: {result}") else: reports.append(result) if not reports: return _update_level_cache(reports) _update_signal_stats(reports, tf) await _refresh_dashboard(bot) actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B", "C")] for chat_id in list(_autoscan_chats): for report in actionable: detail = format_signal_message(report) try: await bot.send_message(chat_id, detail, parse_mode=ParseMode.MARKDOWN) except Exception as exc: logger.warning(f"Failed to send signal for chat {chat_id}: {exc}") # ── WebSocket level-breach monitor ────────────────────────────────── _level_cache: Dict[str, Dict[str, float]] = {} _level_cache_ts: Dict[str, float] = {} _LEVEL_CACHE_TTL = 3600 def _update_level_cache(reports: List[SignalReport]) -> None: now = time.time() for r in reports: if r.primary_analysis and r.primary_analysis.key_levels: levels = { k: v for k, v in r.primary_analysis.key_levels.items() if k in _LEVEL_KEYS } if levels: _level_cache[r.symbol] = levels _level_cache_ts[r.symbol] = now def _check_level_breach(symbol: str, price: float) -> Optional[str]: levels = _level_cache.get(symbol) if not levels: return None cache_age = time.time() - _level_cache_ts.get(symbol, 0) if cache_age > _LEVEL_CACHE_TTL: return None for name, level in levels.items(): diff_pct = abs(price - level) / level if level != 0 else 0 if diff_pct < 0.001: return name return None async def _candle_loop(bot, tf: Timeframe, interval_minutes: int) -> None: label = tf.value try: while _autoscan_chats: wait = _seconds_until_boundary(interval_minutes, buffer=10) logger.info(f"{label} auto-scan: next in {wait:.0f}s") await asyncio.sleep(wait) if not _autoscan_chats: break logger.info(f"{label} auto-scan: firing") await _scan_timeframe( bot, tf, AUTOSCAN_CONFIRMATIONS[tf] ) except asyncio.CancelledError: pass finally: logger.info(f"{label} auto-scan loop stopped") _AUTOSCAN_LOOPS = [ (Timeframe.M5, 5), (Timeframe.M15, 15), (Timeframe.H1, 60), (Timeframe.H4, 240), ] def _start_scheduler(bot) -> None: global _dashboard_task alive = [t for t in _autoscan_tasks if not t.done()] if not alive: _autoscan_tasks.clear() for tf, interval in _AUTOSCAN_LOOPS: _autoscan_tasks.append( asyncio.create_task(_candle_loop(bot, tf, interval)) ) logger.info("Candle-boundary scheduler started (M5/M15/H1/H4)") if _dashboard_task is None or _dashboard_task.done(): _dashboard_task = asyncio.create_task(_dashboard_loop(bot)) logger.info(f"Dashboard {_DASHBOARD_REFRESH_INTERVAL}s refresh loop started") def _stop_scheduler() -> None: global _dashboard_task for t in _autoscan_tasks: t.cancel() _autoscan_tasks.clear() if _dashboard_task and not _dashboard_task.done(): _dashboard_task.cancel() _dashboard_task = None _dashboard_msg_ids.clear() logger.info("Scheduler and dashboard loop stopped") # ── Handlers ────────────────────────────────────────────────────────── async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: scanning = "running" if _autoscan_chats else "off" text = ( "👋 *ForexBot2 — Signal Intelligence*\n\n" "Pick a pair and timeframe to scan.\n" "Signals include a full trade setup with entry, SL and TP.\n\n" f"Auto-scan: *{scanning}* — /autoscan to toggle\n" "/scan — Quick scan buttons\n" "/status — Bot status\n" "/help — All commands" ) await update.message.reply_text( text, parse_mode=ParseMode.MARKDOWN, reply_markup=scan_keyboard(), ) async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: text = ( "📖 *ForexBot2 Commands*\n\n" "/start — Welcome & scan buttons\n" "/scan — Quick scan buttons (XAU/USD + EUR/USD)\n" "/autoscan — Toggle auto-scan on/off\n" "/status — Bot status\n" "/cache — Show data cache age per pair/TF\n" "/clearcache — Wipe data cache, force fresh fetch\n" "/help — This message\n\n" "*Auto-scan* fires at candle close: M5/M15/H1/H4.\n" f"Live dashboard auto-refreshes every {_DASHBOARD_REFRESH_INTERVAL}s." ) await update.message.reply_text(text, parse_mode=ParseMode.MARKDOWN) async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: lines = ["📊 *ForexBot2 Status*\n"] if _autoscan_chats: lines.append(f"✅ Auto-scan: *running* ({len(_autoscan_chats)} chat)") tasks_alive = sum(1 for t in _autoscan_tasks if not t.done()) lines.append(f" Candle loops: {tasks_alive}/{len(_AUTOSCAN_LOOPS)}") else: lines.append("⏹ Auto-scan: *stopped*") cached_pairs = len(_level_cache) cached_levels = sum(len(v) for v in _level_cache.values()) if cached_levels: oldest_age = time.time() - min(_level_cache_ts.values()) if _level_cache_ts else 0 lines.append(f"\n*Level Cache:* {cached_levels} levels across {cached_pairs} pairs (oldest {oldest_age:.0f}s)") await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.MARKDOWN) async def cmd_cache(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: dp = _get_shared_dp() if not dp._cache_timestamps: await update.message.reply_text("📭 Cache is empty — no data fetched yet.") return now = time.time() lines = ["📦 *Data Cache Status*\n"] for pair in SCAN_PAIRS: display = PAIR_INFO.get(pair, {}).get("display", pair) pair_lines = [] for tf in [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4, Timeframe.D1]: cache_key = f"{pair}_{tf.value}" ts = dp._cache_timestamps.get(cache_key) if ts is None: continue age_min = (now - ts) / 60 df = dp._cache.get(cache_key) last_bar = df.index[-1].strftime("%H:%M") if df is not None and not df.empty else "?" fresh = "✅" if age_min < dp.CANDLE_SECONDS.get(tf, 3600) / 60 else "⚠️" pair_lines.append(f" {tf.value:<5} {fresh} fetched `{age_min:.0f}m` ago | last bar `{last_bar}`") if pair_lines: lines.append(f"*{display}*") lines.extend(pair_lines) await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.MARKDOWN) async def cmd_clearcache(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: dp = _get_shared_dp() count = dp.clear_cache() await update.message.reply_text( f"🗑 Cache cleared ({count} entries wiped).\nNext scan will fetch fresh data from API.", parse_mode=ParseMode.MARKDOWN, ) async def cmd_autoscan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: chat_id = update.effective_chat.id if chat_id in _autoscan_chats: _autoscan_chats.discard(chat_id) if not _autoscan_chats: _stop_scheduler() await update.message.reply_text( "⏹ Auto-scan stopped.", parse_mode=ParseMode.MARKDOWN, ) return _autoscan_chats.add(chat_id) _start_scheduler(context.bot) next_m5 = _seconds_until_boundary(5, buffer=10) next_m15 = _seconds_until_boundary(15, buffer=10) next_h1 = _seconds_until_boundary(60, buffer=10) next_h4 = _seconds_until_boundary(240, buffer=10) await update.message.reply_text( "✅ *Auto-scan enabled*\n\n" f"Scanning XAU/USD + EUR/USD on M5/M15/H1/H4 at candle close.\n" f"📊 Live dashboard refreshes every {_DASHBOARD_REFRESH_INTERVAL}s.\n" f"📡 Live price monitoring active.\n" f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | " f"H1 {next_h1 / 60:.0f}m | H4 {next_h4 / 60:.0f}m\n\n" "Use /autoscan again to stop.", parse_mode=ParseMode.MARKDOWN, ) async def cmd_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: await update.message.reply_text( "🔍 *Pick a pair & timeframe*", parse_mode=ParseMode.MARKDOWN, reply_markup=scan_keyboard(), ) async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: query = update.callback_query await query.answer() btn = SCAN_LOOKUP.get(query.data) if not btn: return if btn["tf"] is None: await _cb_scan_all(query, symbol=btn["symbol"]) return display = PAIR_INFO.get(btn["symbol"], {}).get("display", btn["symbol"]) await query.edit_message_text( f"⏳ Scanning {display} {btn['label']}…", parse_mode=ParseMode.MARKDOWN, ) try: report = await run_signal(btn["symbol"], btn["tf"]) text = format_signal_message(report) except Exception as exc: logger.exception("Signal scan failed") text = f"❌ Scan failed: {exc}" await query.edit_message_text( text, parse_mode=ParseMode.MARKDOWN, reply_markup=scan_keyboard(), ) async def _cb_scan_all(query, symbol: str = XAUUSD) -> None: display = PAIR_INFO.get(symbol, {}).get("display", symbol) await query.edit_message_text( f"⏳ Scanning {display} across M5 / M15 / H1 / H4…", parse_mode=ParseMode.MARKDOWN, ) tfs = [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4] tasks = [run_signal(symbol, tf) for tf in tfs] results = await asyncio.gather(*tasks, return_exceptions=True) reports = [] errors = [] for tf, result in zip(tfs, results): if isinstance(result, Exception): errors.append(f"❌ {tf.value}: {result}") else: reports.append(result) _update_signal_stats([result], tf) if reports: _update_level_cache(reports) lines = [ f"📊 *{display} — All Timeframes*", f"🕐 {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}", "", ] for r in reports: emoji = _SIGNAL_EMOJI.get(r.final_signal, "⬜") qe = _QUALITY_EMOJI.get(r.signal_quality, "") tf_label = r.primary_analysis.timeframe if r.primary_analysis else "?" sig = _signal_label(r.final_signal) regime = r.market_regime.replace("_", " ").title() lines.append( f"{emoji} *{tf_label}* {sig} {qe}{r.signal_quality} " f"Score: {r.final_score:+.1f} {r.final_confidence:.0f}%" ) lines.append(f" _{_escape_md(regime)}_ | {'✅ MTF' if r.mtf_agreement else '❌ MTF'}") if r.trade_setup and r.final_signal != SignalStrength.NEUTRAL: ts = r.trade_setup d = "📈" if ts.direction == "BUY" else "📉" lines.append( f" {d} {ts.direction} `{_fmt_price(ts.entry_price)}` " f"SL `{_fmt_price(ts.stop_loss)}` TP `{_fmt_price(ts.take_profit_1)}` " f"RR 1:{ts.risk_reward:.1f}" ) lines.append("") for err in errors: lines.append(err) lines.append("_Use buttons below for detailed single-TF view._") await query.edit_message_text( "\n".join(lines), parse_mode=ParseMode.MARKDOWN, reply_markup=scan_keyboard(), ) # ── Boot-time auto-start ────────────────────────────────────────────── async def _post_init(app: Application) -> None: """Register command menu and start scheduler on boot.""" await app.bot.set_my_commands([ ("start", "Welcome & scan buttons"), ("scan", "Quick scan buttons (XAU/USD + EUR/USD)"), ("autoscan", "Toggle auto-scan on/off"), ("status", "Bot status, WS connection, live prices"), ("cache", "Show data cache age per pair/TF"), ("clearcache", "Wipe data cache, force fresh fetch"), ("help", "All commands"), ]) if not CHAT_ID: logger.info("TELEGRAM_CHAT_ID not set — auto-scan disabled on boot. " "Use /autoscan in chat to enable.") return chat_id = int(CHAT_ID) _autoscan_chats.add(chat_id) logger.info("Running initial scan to populate level cache and signal stats...") try: tasks = [ asyncio.wait_for(run_signal(sym, Timeframe.M15), timeout=30) for sym in SCAN_PAIRS ] results = await asyncio.gather(*tasks, return_exceptions=True) init_reports = [r for r in results if isinstance(r, SignalReport)] for i, r in enumerate(results): if isinstance(r, Exception): logger.error(f"Initial scan failed for {SCAN_PAIRS[i]}: {r}") if init_reports: _update_level_cache(init_reports) _update_signal_stats(init_reports, Timeframe.M15) logger.info(f"Level cache populated: {sum(len(v) for v in _level_cache.values())} levels") except Exception: logger.exception("Initial scan failed — WS will wait for first candle scan") _start_scheduler(app.bot) next_m5 = _seconds_until_boundary(5, buffer=10) next_m15 = _seconds_until_boundary(15, buffer=10) next_h1 = _seconds_until_boundary(60, buffer=10) next_h4 = _seconds_until_boundary(240, buffer=10) logger.info( f"Auto-scan started for chat {chat_id} — " f"M5 {next_m5:.0f}s, M15 {next_m15:.0f}s, H1 {next_h1:.0f}s, H4 {next_h4:.0f}s" ) try: await app.bot.send_message( chat_id, f"🤖 *ForexBot2 started*\n\n" f"Auto-scanning XAU/USD + EUR/USD on M5/M15/H1/H4.\n" f"📊 Live dashboard refreshes every {_DASHBOARD_REFRESH_INTERVAL}s.\n" f"📡 Live price monitoring active.\n" f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | " f"H1 {next_h1 / 60:.0f}m | H4 {next_h4 / 60:.0f}m\n\n" f"/autoscan to stop", parse_mode=ParseMode.MARKDOWN, ) await _send_dashboard(app.bot, chat_id) except Exception: logger.exception("Failed to send boot message") # ── Graceful shutdown ───────────────────────────────────────────────── async def _post_shutdown(app: Application) -> None: logger.info("Shutting down ForexBot2...") _stop_scheduler() _executor.shutdown(wait=False) logger.info("Shutdown complete") # ── Entry point ─────────────────────────────────────────────────────── def main() -> None: if not TOKEN: sys.exit("TELEGRAM_TOKEN not set") logger.info("Starting ForexBot2 Telegram bot…") app = ( Application.builder() .token(TOKEN) .post_init(_post_init) .post_shutdown(_post_shutdown) .build() ) app.add_handler(CommandHandler("start", cmd_start)) app.add_handler(CommandHandler("scan", cmd_scan)) app.add_handler(CommandHandler("help", cmd_help)) app.add_handler(CommandHandler("status", cmd_status)) app.add_handler(CommandHandler("autoscan", cmd_autoscan)) app.add_handler(CommandHandler("cache", cmd_cache)) app.add_handler(CommandHandler("clearcache", cmd_clearcache)) app.add_handler(CallbackQueryHandler(cb_scan, pattern="^scan_")) logger.info("Bot running — press Ctrl+C to stop") app.run_polling(allowed_updates=Update.ALL_TYPES) if __name__ == "__main__": main()