From da6fc5d2833451c7e424a833699b0d5dbcfdea2b Mon Sep 17 00:00:00 2001 From: Harwinder Date: Mon, 4 May 2026 15:50:49 -0400 Subject: [PATCH] =?UTF-8?q?Simplify=20Telegram=20bot=20=E2=80=94=20mirror?= =?UTF-8?q?=20main.py=20output=20exactly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove /all, /watch, /unwatch, /credits, /status commands - Keep only /scan, /pair, /tf, /help - Output exact same format as main.py terminal output (box-drawing characters, score bars, table, detail report) inside
 blocks
- /help explains all timeframes with recommended use cases
- /scan TF and /pair EURUSD TF accept inline timeframe override
- Auto-split messages at newlines to stay under Telegram 4096-char limit

Co-Authored-By: Claude Sonnet 4.6 
---
 telegram_bot.py | 715 ++++++++++++++++++------------------------------
 1 file changed, 267 insertions(+), 448 deletions(-)

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)