diff --git a/telegram_bot.py b/telegram_bot.py index 9d2dce5..d270f8d 100644 --- a/telegram_bot.py +++ b/telegram_bot.py @@ -2,22 +2,17 @@ """ Forex Signal Intelligence Platform - Telegram Bot =================================================== -Exposes all platform functionality through a Telegram bot interface. +Mirrors main.py output exactly via Telegram. -Usage: - export TELEGRAM_BOT_TOKEN="your_bot_token_here" +Setup: + export TELEGRAM_BOT_TOKEN="your_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 + /scan [TF] — Full signal scan (all major pairs) + /pair EURUSD [TF] — Detailed analysis for one pair /tf H1 — Set default timeframe - /watch [minutes] — Enable periodic scan alerts - /unwatch — Disable alerts - /status — Current settings - /help — Full command reference + /help — Commands + timeframe guide """ import asyncio @@ -25,21 +20,21 @@ import logging import os from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone -from typing import Dict, Optional +from typing import Dict, List, Optional from telegram import BotCommand, Update from telegram.constants import ParseMode -from telegram.ext import Application, CallbackContext, CommandHandler, ContextTypes +from telegram.ext import Application, CommandHandler, ContextTypes from config import ( - CROSS_PAIRS, MAJOR_PAIRS, SYMBOL_DISPLAY, + MAJOR_PAIRS, SYMBOL_DISPLAY, PlatformConfig, SignalStrength, Timeframe, ) from signal_aggregator import SignalAggregator, SignalReport logger = logging.getLogger(__name__) -# ── Constants ────────────────────────────────────────────────────── +# ── Timeframe config ─────────────────────────────────────────────── TF_MAP = { "M5": Timeframe.M5, @@ -59,34 +54,162 @@ TF_CONFIRMATIONS = { "D1": [Timeframe.H4, Timeframe.W1], } -SIGNAL_EMOJI = { - SignalStrength.STRONG_BUY: "🟢⬆⬆", - SignalStrength.BUY: "🟢⬆", - SignalStrength.NEUTRAL: "⬜↔", - SignalStrength.SELL: "🔴⬇", - SignalStrength.STRONG_SELL: "🔴⬇⬇", -} +# ── Formatting (mirrors main.py, no ANSI colours) ────────────────── -SIGNAL_NAME = { - SignalStrength.STRONG_BUY: "STRONG BUY", - SignalStrength.BUY: "BUY", - SignalStrength.NEUTRAL: "NEUTRAL", - SignalStrength.SELL: "SELL", - SignalStrength.STRONG_SELL: "STRONG SELL", -} +WIDTH = 80 -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 +def _score_bar(score: float, width: int = 30) -> str: + normalized = (score + 100) / 200 + filled = int(normalized * width) + center = width // 2 + bar = "" + for i in range(width): + if i == center: + bar += "│" + elif (score >= 0 and center < i <= filled) or \ + (score < 0 and filled <= i < center): + bar += "█" + else: + bar += "░" + return bar + + +def _format_header(timeframe: str) -> str: + now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + lines = [ + "", + "═" * WIDTH, + " ╔═══════════════════════════════════════════════════════╗", + " ║ FOREX SIGNAL INTELLIGENCE PLATFORM ║", + " ║ Multi-Layer Signal Analysis Engine ║", + " ╚═══════════════════════════════════════════════════════╝", + f" Scan Time: {now} TF: {timeframe}", + "═" * WIDTH, + "", + ] + return "\n".join(lines) + + +def _format_table(reports: List[SignalReport]) -> str: + lines = [ + " ┌─────────────┬───────────┬──────────────────────────────────┬────────┬────────────┐", + " │ Pair │ Signal │ Score │ Grade │ Confidence │", + " ├─────────────┼───────────┼──────────────────────────────────┼────────┼────────────┤", + ] + for r in reports: + sig = r.final_signal.name.replace("_", " ") + bar = _score_bar(r.final_score, 30) + pair = f"{r.display_name:<11}" + signal_str = f"{sig:<9}" + grade_str = f"{r.signal_quality:<6}" + conf_str = f"{r.final_confidence:5.1f}%" + warn = " ⚠" if r.event_risk == "danger" else (" △" if r.event_risk == "caution" else " ") + lines.append(f" │ {pair} │ {signal_str} │ {bar} │ {grade_str} │ {conf_str:>8}{warn} │") + lines.append( + " └─────────────┴───────────┴──────────────────────────────────┴────────┴────────────┘" + ) + lines += [ + "", + " Legend: ██ Bullish | ██ Bearish | │ Center (0) | ⚠ Event Risk | △ Caution", + ] + return "\n".join(lines) + + +def _format_calendar(reports: List[SignalReport]) -> str: + events_shown = set() + lines = [] + for r in reports: + if r.event_risk != "clear" and r.event_description not in events_shown: + events_shown.add(r.event_description) + lines.append(f" ● {r.event_description} → affects {r.display_name}") + if lines: + return "\n 📅 ECONOMIC CALENDAR ALERTS\n" + "\n".join(lines) + return "\n 📅 No high-impact events in the near term" + + +def _format_detail(r: SignalReport) -> List[str]: + """ + Returns a list of message strings for one pair's detail report. + Split into two messages so each stays under Telegram's 4096-char limit. + """ + sig_name = r.final_signal.name.replace("_", " ") + + # ── Part 1: header, signal, MTF, indicators ──────────────────── + p1 = ["─" * WIDTH] + p1.append( + f" {r.display_name} │ Price: {r.current_price:.5f}" + f" │ Regime: {r.market_regime} │ Vol: {r.volatility_regime}" + ) + p1.append( + f"\n SIGNAL: {sig_name} │ Score: {r.final_score:+.1f}" + f" │ Grade: {r.signal_quality} │ Confidence: {r.final_confidence:.0f}%" + ) + p1.append(f" Action: {r.action_recommendation}") + + if r.mtf_signals: + agree = "✓ ALIGNED" if r.mtf_agreement else "✗ DIVERGENT" + p1.append(f"\n Multi-Timeframe: {agree}") + for tf, sig in r.mtf_signals.items(): + score = r.mtf_scores.get(tf, 0) + p1.append(f" {tf:>5}: {sig.name.replace('_', ' '):<12} ({score:+.1f})") + + if r.primary_analysis: + p1.append(f"\n Indicators ({r.primary_analysis.timeframe}):") + for name, ind in r.primary_analysis.indicators.items(): + if name.startswith("_") or ind.weight == 0: + continue + ind_name = f"{ind.name:<18}" + sig_str = f"{ind.signal.name.replace('_', ' '):<12}" + desc = ind.description[:60] + p1.append(f" {ind_name} {sig_str} ({ind.confidence:.0f}%) │ {desc}") + + # ── Part 2: reasons, warnings, trade setup, key levels ───────── + p2 = [] + + if r.bullish_reasons: + p2.append(" ▲ Bullish Factors:") + for reason in r.bullish_reasons[:5]: + p2.append(f" + {reason}") + + if r.bearish_reasons: + p2.append("\n ▼ Bearish Factors:") + for reason in r.bearish_reasons[:5]: + p2.append(f" - {reason}") + + if r.warnings: + p2.append("\n ⚠ Warnings:") + for w in r.warnings: + p2.append(f" ! {w}") + + if r.news_description and r.news_description != "Neutral news sentiment": + p2.append(f"\n 📰 News: {r.news_description}") + if r.event_description and r.event_risk != "clear": + p2.append(f" 📅 Event: {r.event_description}") + + if r.trade_setup: + ts = r.trade_setup + p2 += [ + "\n Trade Setup:", + f" Direction: {ts.direction}", + f" Entry: {ts.entry_price:.5f}", + f" Stop Loss: {ts.stop_loss:.5f} ({ts.risk_pips:.1f} pips)", + f" Take Profit 1: {ts.take_profit_1:.5f} ({ts.reward_pips:.1f} pips)", + f" Take Profit 2: {ts.take_profit_2:.5f}", + f" Risk:Reward: 1:{ts.risk_reward:.1f}", + ] + + if r.primary_analysis and r.primary_analysis.key_levels: + lvls = r.primary_analysis.key_levels + p2.append("\n Key Levels:") + for k in ("r3", "r2", "r1", "pivot", "s1", "s2", "s3"): + if k in lvls: + p2.append(f" {k.upper():>6}: {lvls[k]:.5f}") + + result = ["\n".join(p1)] + if p2: + result.append("\n".join(p2)) + return result # ── Per-chat state ───────────────────────────────────────────────── @@ -94,274 +217,88 @@ WATCH_DEFAULT = 5 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("&", "&").replace("<", "<").replace(">", ">") - - -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"📊 {title} — {timeframe}", - "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", - f"🕐 {now}\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} {_esc(r.display_name)} " - f"{grade_em}{r.signal_quality} " - f"{_esc(sig_name):<11} " - f"{r.final_confidence:.0f}%{event_flag}" - ) - - lines += ["", "Use /pair EURUSD for detailed analysis"] - 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} {_esc(r.display_name)} — {_esc(sig_name)}", - "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", - f"Price: {r.current_price:.5f}", - f"Regime: {_esc(r.market_regime)} Vol: {_esc(r.volatility_regime)}", - "", - f"Score: {r.final_score:+.1f} " - f"Grade: {grade_em}{r.signal_quality} " - f"Conf: {r.final_confidence:.0f}%", - f"Action: {_esc(r.action_recommendation)}", - ] - - if r.event_risk != "clear": - icon = "⚠️" if r.event_risk == "danger" else "⚡" - m1.append(f"{icon} Event: {_esc(r.event_description)}") - - if r.news_description and r.news_description != "Neutral news sentiment": - m1.append(f"📰 News: {_esc(r.news_description)}") - - # Multi-timeframe - if r.mtf_signals: - agree = "✅ ALIGNED" if r.mtf_agreement else "⚡ DIVERGENT" - m1 += ["", f"📊 Multi-Timeframe: {agree}"] - for tf, sig in r.mtf_signals.items(): - score = r.mtf_scores.get(tf, 0) - m1.append( - f" {tf:>5} {SIGNAL_EMOJI.get(sig, '?')} " - f"{score:+.1f}" - ) - - # Trade setup - if r.trade_setup: - ts = r.trade_setup - dir_em = "🟢" if ts.direction == "BUY" else "🔴" - m1 += [ - "", - "💼 Trade Setup", - f" Direction: {dir_em} {ts.direction}", - f" Entry: {ts.entry_price:.5f}", - f" Stop Loss: {ts.stop_loss:.5f} ({ts.risk_pips:.1f} pips)", - f" Take Profit 1: {ts.take_profit_1:.5f} ({ts.reward_pips:.1f} pips)", - f" Take Profit 2: {ts.take_profit_2:.5f}", - f" Risk:Reward: 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 += ["", "📍 Key Levels:"] - for k, v in shown: - arrow = "🔴" if v > r.current_price else "🟢" - m1.append(f" {arrow} {k.upper():>6}: {v:.5f}") - - # ── 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} {_esc(ind.name)} {ind.confidence:.0f}%" - f"\n {desc}" - ) - if ind_lines: - m2 += ["📉 Indicators:"] + ind_lines - - if r.bullish_reasons: - if m2: - m2.append("") - m2.append("🟢 Bullish Factors:") - for reason in r.bullish_reasons[:5]: - m2.append(f" ▲ {_esc(reason)}") - - if r.bearish_reasons: - if m2: - m2.append("") - m2.append("🔴 Bearish Factors:") - for reason in r.bearish_reasons[:5]: - m2.append(f" ▼ {_esc(reason)}") - - if r.warnings: - if m2: - m2.append("") - m2.append("⚠️ Warnings:") - for w in r.warnings: - m2.append(f" ! {_esc(w)}") - - result = _split_messages(m1) - if m2: - result += _split_messages(m2) - return result # ══════════════════════════════════════════════════════════════════ -# BOT CLASS +# BOT # ══════════════════════════════════════════════════════════════════ class ForexTelegramBot: def __init__(self): - self._chat_states: Dict[int, ChatState] = {} + self._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] + if chat_id not in self._states: + self._states[chat_id] = ChatState() + return self._states[chat_id] - def _build_config(self, state: ChatState) -> PlatformConfig: + def _build_config(self, tf: str, pairs: list = None) -> 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) + config.primary_timeframe = TF_MAP[tf] + config.confirmation_timeframes = TF_CONFIRMATIONS.get(tf, [Timeframe.M15, Timeframe.H4]) + config.watch_pairs = list(pairs or MAJOR_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 + def _run_scan(self, tf: str, pairs: list) -> List[SignalReport]: + return SignalAggregator(self._build_config(tf, pairs)).analyze_all_pairs() 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.""" + def _parse_tf(args: list) -> Optional[str]: for a in args: if a.upper() in TF_MAP: return a.upper() return None + @staticmethod + async def _send_pre(update: Update, text: str): + """Send text as a monospace pre block, splitting if over 4096 chars.""" + LIMIT = 3800 # leave room for
 tags
+        chunks = []
+        while text:
+            if len(text) <= LIMIT:
+                chunks.append(text)
+                break
+            # Split at last newline before limit
+            split_at = text.rfind("\n", 0, LIMIT)
+            if split_at == -1:
+                split_at = LIMIT
+            chunks.append(text[:split_at])
+            text = text[split_at:].lstrip("\n")
+        for chunk in chunks:
+            await update.message.reply_text(
+                f"
{chunk}
", parse_mode=ParseMode.HTML + ) + # ── Commands ─────────────────────────────────────────────────── async def cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE): - text = ( - "🌐 Forex Signal Intelligence Platform\n" - "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n" - "Multi-layer technical + fundamental signal analysis across major Forex pairs.\n\n" - "Commands:\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" - "⚠️ Educational/research use only. Not financial advice." - ) - await update.message.reply_text(text, parse_mode=ParseMode.HTML) + await self.cmd_help(update, context) async def cmd_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE): - text = ( - "📖 Command Reference\n\n" - "/scan — Scan all major pairs (uses default TF)\n" - "/scan H4 — Scan with a specific timeframe\n\n" - "/signals — Grade B+ signals only\n" - "/signals H4 — With a specific timeframe\n\n" - "/pair EURUSD — Detailed analysis (uses default TF)\n" - "/pair EURUSD H4 — With a specific timeframe\n\n" - "/all — Scan major + cross pairs\n" - "/all H4 — With a specific timeframe\n\n" - "/tf H1 — Set your default timeframe\n" - " Options: M5 M15 M30 H1 H4 D1\n\n" - "/watch — Alert every 5 min (actionable signals only)\n" - "/watch 15 — Alert every 15 min (5–120 min range)\n" - "/unwatch — Stop alerts\n\n" - "/status — View current settings\n\n" - "Signal key:\n" - " 🟢⬆⬆ Strong Buy 🟢⬆ Buy ⬜↔ Neutral\n" - " 🔴⬇ Sell 🔴⬇⬇ Strong Sell\n\n" - "Grade key:\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 = ( - "⚙️ Current Settings\n\n" - f"Timeframe: {state.timeframe}\n" - f"Pairs: {_esc(pairs_display)}\n" - f"Watch alerts: {watch_str}" + "🌐 Forex Signal Intelligence Platform\n\n" + "Commands:\n" + " /scan — Scan all major pairs\n" + " /scan H4 — Scan with specific timeframe\n" + " /pair EURUSD — Detailed analysis\n" + " /pair EURUSD H4 — With specific timeframe\n" + " /tf H1 — Set 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 ← recommended for intraday\n" + " H4 — 4 hours ← recommended for swing trading\n" + " D1 — Daily ← recommended for position trading\n\n" + f"Current default: {state.timeframe}\n\n" + "⚠️ Educational use only. Not financial advice." ) await update.message.reply_text(text, parse_mode=ParseMode.HTML) @@ -370,7 +307,8 @@ class ForexTelegramBot: if not tf: opts = " ".join(TF_MAP.keys()) await update.message.reply_text( - f"Usage: /tf <timeframe>\nOptions: {opts}", + f"Usage: /tf <timeframe>\n" + f"Options: {opts}", parse_mode=ParseMode.HTML, ) return @@ -382,45 +320,42 @@ class ForexTelegramBot: async def cmd_scan(self, update: Update, context: ContextTypes.DEFAULT_TYPE): chat_id = update.effective_chat.id state = self._state(chat_id) + tf = self._parse_tf(context.args or []) or state.timeframe - # 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…") + msg = await update.message.reply_text(f"🔄 Scanning markets ({tf})…") 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) + reports = await self._analyze(self._run_scan, tf, list(MAJOR_PAIRS)) - async def cmd_signals(self, update: Update, context: ContextTypes.DEFAULT_TYPE): - chat_id = update.effective_chat.id - state = self._state(chat_id) + # ── summary block (header + table + calendar) ────────── + summary = ( + _format_header(tf) + + _format_table(reports) + + _format_calendar(reports) + ) + await msg.edit_text(f"
{summary}
", parse_mode=ParseMode.HTML) - 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) + # ── detailed block for each actionable signal ────────── 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") + header_line = "\n ═══ ACTIONABLE SIGNALS (Grade B or above) ═══\n" + first = True + for r in actionable: + parts = _format_detail(r) + if first: + parts[0] = header_line + parts[0] + first = False + for part in parts: + await self._send_pre(update, part) else: - text = ( - f"🎯 ACTIONABLE SIGNALS — {scan_state.timeframe}\n\n" - "No grade B+ signals right now.\n" - "Use /scan to see all pairs." + await self._send_pre( + update, + "\n No high-grade signals detected.\n" + " Use /scan with a different timeframe to see more.\n", ) - 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) + logger.error(f"Scan error: {e}") + await msg.edit_text(f"❌ Scan failed: {e}") async def cmd_pair(self, update: Update, context: ContextTypes.DEFAULT_TYPE): chat_id = update.effective_chat.id @@ -438,127 +373,26 @@ class ForexTelegramBot: 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 - + tf = self._parse_tf(args[1:]) or state.timeframe display = SYMBOL_DISPLAY.get(raw, raw) - msg = await update.message.reply_text(f"🔄 Analyzing {_esc(display)}…") + + msg = await update.message.reply_text(f"🔄 Analyzing {display} ({tf})…") try: - report = await self._analyze(self._run_pair, raw, pair_state) - if report is None: - await msg.edit_text( - f"❌ No data returned for {_esc(raw)}.", - parse_mode=ParseMode.HTML, - ) + reports = await self._analyze(self._run_scan, tf, [raw]) + if not reports: + await msg.edit_text(f"❌ No data returned for {raw}") 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) + + r = reports[0] + summary = _format_header(tf) + _format_table([r]) + await msg.edit_text(f"
{summary}
", parse_mode=ParseMode.HTML) + + for part in _format_detail(r): + await self._send_pre(update, part) + 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 {interval_minutes} min.\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.") - - async def cmd_credits(self, update: Update, context: ContextTypes.DEFAULT_TYPE): - """Show API credit usage against the free-tier daily limit.""" - from data_provider import ForexDataProvider - config = self._build_config(self._state(update.effective_chat.id)) - provider = ForexDataProvider(config) - used = provider._daily_credits - limit = provider.DAILY_LIMIT - remaining = limit - used - pct = (used / limit) * 100 if limit else 0 - bar_filled = int(pct / 5) - bar = "█" * bar_filled + "░" * (20 - bar_filled) - status = "🟢" if pct < 60 else ("🟡" if pct < 80 else "🔴") - text = ( - "📊 API Credit Usage\n\n" - f"{bar} {status}\n" - f"Used: {used} / {limit} credits\n" - f"Remaining: {remaining} credits\n\n" - "Free tier: 8 credits/min, 800 credits/day\n" - "Each full scan uses ~21 credits (7 pairs × 3 timeframes)\n" - "≈ 38 scans available per day" - ) - await update.message.reply_text(text, parse_mode=ParseMode.HTML) - - # ── 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 = ( - "🔔 SIGNAL ALERT\n\n" - + _format_scan(actionable, state.timeframe, title="ACTIONABLE SIGNALS") - ) - else: - now = datetime.now(timezone.utc).strftime("%H:%M UTC") - text = f"📊 Watch scan {now} — 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}") + logger.error(f"Pair error for {raw}: {e}") + await msg.edit_text(f"❌ Analysis failed: {e}") # ── Application builder ──────────────────────────────────────── @@ -572,18 +406,11 @@ class ForexTelegramBot: ) async def _register_commands(app: Application) -> None: - """Register commands with Telegram so they appear in the / menu.""" await app.bot.set_my_commands([ - BotCommand("scan", "Scan all major pairs"), - BotCommand("signals", "Actionable signals only (grade B+)"), - BotCommand("pair", "Detailed pair analysis — /pair EURUSD"), - BotCommand("all", "Scan major + cross pairs"), - BotCommand("tf", "Set timeframe — /tf H4"), - BotCommand("watch", "Enable periodic alerts"), - BotCommand("unwatch", "Disable alerts"), - BotCommand("credits", "View API credit usage"), - BotCommand("status", "View current settings"), - BotCommand("help", "Full command reference"), + BotCommand("scan", "Scan all major pairs"), + BotCommand("pair", "Detailed pair analysis — /pair EURUSD"), + BotCommand("tf", "Set default timeframe — /tf H4"), + BotCommand("help", "Commands and timeframe guide"), ]) app = ( @@ -593,17 +420,11 @@ class ForexTelegramBot: .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)) - app.add_handler(CommandHandler("credits", self.cmd_credits)) + 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("scan", self.cmd_scan)) + app.add_handler(CommandHandler("pair", self.cmd_pair)) return app @@ -616,10 +437,8 @@ def main(): 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)