Simplify Telegram bot — mirror main.py output exactly

- 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 <pre> 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 <noreply@anthropic.com>
This commit is contained in:
Harwinder
2026-05-04 15:50:49 -04:00
parent 0e95a34d82
commit da6fc5d283
+267 -448
View File
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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"<b>📊 {title} — {timeframe}</b>",
"<code>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</code>",
f"<code>🕐 {now}</code>\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} <b>{_esc(r.display_name)}</b> "
f"{grade_em}<code>{r.signal_quality}</code> "
f"<code>{_esc(sig_name):<11}</code> "
f"<code>{r.final_confidence:.0f}%</code>{event_flag}"
)
lines += ["", "<i>Use /pair EURUSD for detailed analysis</i>"]
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} <b>{_esc(r.display_name)}</b> — <b>{_esc(sig_name)}</b>",
"<code>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</code>",
f"<b>Price:</b> <code>{r.current_price:.5f}</code>",
f"<b>Regime:</b> {_esc(r.market_regime)} <b>Vol:</b> {_esc(r.volatility_regime)}",
"",
f"<b>Score:</b> <code>{r.final_score:+.1f}</code> "
f"<b>Grade:</b> {grade_em}<code>{r.signal_quality}</code> "
f"<b>Conf:</b> <code>{r.final_confidence:.0f}%</code>",
f"<b>Action:</b> {_esc(r.action_recommendation)}",
]
if r.event_risk != "clear":
icon = "⚠️" if r.event_risk == "danger" else "⚡"
m1.append(f"{icon} <b>Event:</b> {_esc(r.event_description)}")
if r.news_description and r.news_description != "Neutral news sentiment":
m1.append(f"📰 <b>News:</b> {_esc(r.news_description)}")
# Multi-timeframe
if r.mtf_signals:
agree = "✅ ALIGNED" if r.mtf_agreement else "⚡ DIVERGENT"
m1 += ["", f"<b>📊 Multi-Timeframe:</b> {agree}"]
for tf, sig in r.mtf_signals.items():
score = r.mtf_scores.get(tf, 0)
m1.append(
f" <code>{tf:>5}</code> {SIGNAL_EMOJI.get(sig, '?')} "
f"<code>{score:+.1f}</code>"
)
# Trade setup
if r.trade_setup:
ts = r.trade_setup
dir_em = "🟢" if ts.direction == "BUY" else "🔴"
m1 += [
"",
"<b>💼 Trade Setup</b>",
f" <b>Direction:</b> {dir_em} {ts.direction}",
f" <b>Entry:</b> <code>{ts.entry_price:.5f}</code>",
f" <b>Stop Loss:</b> <code>{ts.stop_loss:.5f}</code> ({ts.risk_pips:.1f} pips)",
f" <b>Take Profit 1:</b> <code>{ts.take_profit_1:.5f}</code> ({ts.reward_pips:.1f} pips)",
f" <b>Take Profit 2:</b> <code>{ts.take_profit_2:.5f}</code>",
f" <b>Risk:Reward:</b> 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 += ["", "<b>📍 Key Levels:</b>"]
for k, v in shown:
arrow = "🔴" if v > r.current_price else "🟢"
m1.append(f" {arrow} <code>{k.upper():>6}:</code> <code>{v:.5f}</code>")
# ── 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} <b>{_esc(ind.name)}</b> <code>{ind.confidence:.0f}%</code>"
f"\n <i>{desc}</i>"
)
if ind_lines:
m2 += ["<b>📉 Indicators:</b>"] + ind_lines
if r.bullish_reasons:
if m2:
m2.append("")
m2.append("<b>🟢 Bullish Factors:</b>")
for reason in r.bullish_reasons[:5]:
m2.append(f" ▲ {_esc(reason)}")
if r.bearish_reasons:
if m2:
m2.append("")
m2.append("<b>🔴 Bearish Factors:</b>")
for reason in r.bearish_reasons[:5]:
m2.append(f" ▼ {_esc(reason)}")
if r.warnings:
if m2:
m2.append("")
m2.append("<b>⚠️ Warnings:</b>")
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 <pre> 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"<pre>{chunk}</pre>", parse_mode=ParseMode.HTML
)
# ── Commands ───────────────────────────────────────────────────
async def cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
text = (
"<b>🌐 Forex Signal Intelligence Platform</b>\n"
"<code>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</code>\n\n"
"Multi-layer technical + fundamental signal analysis across major Forex pairs.\n\n"
"<b>Commands:</b>\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"
"<i>⚠️ Educational/research use only. Not financial advice.</i>"
)
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 = (
"<b>📖 Command Reference</b>\n\n"
"<b>/scan</b> — Scan all major pairs (uses default TF)\n"
"<b>/scan H4</b> — Scan with a specific timeframe\n\n"
"<b>/signals</b> — Grade B+ signals only\n"
"<b>/signals H4</b> — With a specific timeframe\n\n"
"<b>/pair EURUSD</b> — Detailed analysis (uses default TF)\n"
"<b>/pair EURUSD H4</b> — With a specific timeframe\n\n"
"<b>/all</b> — Scan major + cross pairs\n"
"<b>/all H4</b> — With a specific timeframe\n\n"
"<b>/tf H1</b> — Set your default timeframe\n"
" Options: <code>M5 M15 M30 H1 H4 D1</code>\n\n"
"<b>/watch</b> — Alert every 5 min (actionable signals only)\n"
"<b>/watch 15</b> — Alert every 15 min (5–120 min range)\n"
"<b>/unwatch</b> — Stop alerts\n\n"
"<b>/status</b> — View current settings\n\n"
"<b>Signal key:</b>\n"
" 🟢⬆⬆ Strong Buy 🟢⬆ Buy ⬜↔ Neutral\n"
" 🔴⬇ Sell 🔴⬇⬇ Strong Sell\n\n"
"<b>Grade key:</b>\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 = (
"<b>⚙️ Current Settings</b>\n\n"
f"<b>Timeframe:</b> {state.timeframe}\n"
f"<b>Pairs:</b> {_esc(pairs_display)}\n"
f"<b>Watch alerts:</b> {watch_str}"
"<b>🌐 Forex Signal Intelligence Platform</b>\n\n"
"<b>Commands:</b>\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"
"<b>Timeframes:</b>\n"
" <code>M5 </code> — 5 minutes\n"
" <code>M15</code> — 15 minutes\n"
" <code>M30</code> — 30 minutes\n"
" <code>H1 </code> — 1 hour ← recommended for intraday\n"
" <code>H4 </code> — 4 hours ← recommended for swing trading\n"
" <code>D1 </code> — Daily ← recommended for position trading\n\n"
f"Current default: <b>{state.timeframe}</b>\n\n"
"<i>⚠️ Educational use only. Not financial advice.</i>"
)
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: <b>/tf &lt;timeframe&gt;</b>\nOptions: <code>{opts}</code>",
f"Usage: <b>/tf &lt;timeframe&gt;</b>\n"
f"Options: <code>{opts}</code>",
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"<pre>{summary}</pre>", 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"<b>🎯 ACTIONABLE SIGNALS — {scan_state.timeframe}</b>\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 <code>{_esc(raw)}</code>.",
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"<pre>{summary}</pre>", 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 <b>{interval_minutes} min</b>.\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 = (
"<b>📊 API Credit Usage</b>\n\n"
f"<code>{bar}</code> {status}\n"
f"<b>Used:</b> {used} / {limit} credits\n"
f"<b>Remaining:</b> {remaining} credits\n\n"
"<i>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</i>"
)
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 = (
"🔔 <b>SIGNAL ALERT</b>\n\n"
+ _format_scan(actionable, state.timeframe, title="ACTIONABLE SIGNALS")
)
else:
now = datetime.now(timezone.utc).strftime("%H:%M UTC")
text = f"📊 <b>Watch scan {now}</b> — 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)