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)