Rebuild Telegram bot with trade alert template and grade-filtered scan

- Replace /scan+/pair commands with /signal (single pair) and /scan (all pairs, B+ only)
- Implement exact visual template: grade dot (🟢/🟡/🔴), TRADE ALERT header, Entry/TP/SL/R:R block, full indicator list with value + condition
- Grade dot enforced: A/A+=🟢, B=🟡, C/D/F=🔴
- /scan filters to minimum Grade B confidence before sending alerts
- HTML parse mode with html.escape() on all dynamic content
- Per-chat default timeframe state preserved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Harwinder
2026-05-04 17:16:00 -04:00
parent 94b93cf1e7
commit 4aba243472
+257 -237
View File
@@ -1,21 +1,22 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Forex Signal Intelligence Platform - Telegram Bot Forex Signal Intelligence Platform - Telegram Bot
=================================================== ==================================================
Mirrors main.py output exactly via Telegram. Trade alert frontend for the signal engine.
Setup: Setup:
export TELEGRAM_BOT_TOKEN="your_token_here" set TELEGRAM_BOT_TOKEN=your_token_here
python telegram_bot.py python telegram_bot.py
Commands: Commands:
/scan [TF] — Full signal scan (all major pairs) /signal EURUSD [TF] — Signal card for one pair
/pair EURUSD [TF] — Detailed analysis for one pair /scan [TF] — Scan all pairs, show Grade B+ alerts
/tf H1 — Set default timeframe /tf H1 — Set default timeframe
/help — Commands + timeframe guide /help — Command guide
""" """
import asyncio import asyncio
import html
import logging import logging
import os import os
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
@@ -34,7 +35,7 @@ from signal_aggregator import SignalAggregator, SignalReport
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── Timeframe config ─────────────────────────────────────────────── # ── Timeframe maps ──────────────────────────────────────────────────
TF_MAP = { TF_MAP = {
"M5": Timeframe.M5, "M5": Timeframe.M5,
@@ -54,193 +55,196 @@ TF_CONFIRMATIONS = {
"D1": [Timeframe.H4, Timeframe.W1], "D1": [Timeframe.H4, Timeframe.W1],
} }
# ── Formatting (mirrors main.py, no ANSI colours) ────────────────── # ── Formatting helpers ──────────────────────────────────────────────
WIDTH = 80 def _grade_dot(quality: str) -> str:
"""Colored dot matching confidence grade per spec."""
if quality in ("A+", "A"):
return "🟢"
if quality == "B":
return "🟡"
return "🔴"
def _score_bar(score: float, width: int = 30) -> str: def _signal_action(signal: SignalStrength) -> str:
normalized = (score + 100) / 200 return {
filled = int(normalized * width) SignalStrength.STRONG_BUY: "STRONG BUY",
center = width // 2 SignalStrength.BUY: "BUY",
bar = "" SignalStrength.STRONG_SELL: "STRONG SELL",
for i in range(width): SignalStrength.SELL: "SELL",
if i == center: SignalStrength.NEUTRAL: "NEUTRAL",
bar += "│" }.get(signal, "NEUTRAL")
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: def _indicator_condition(ind) -> str:
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") """
lines = [ Extract a short readable condition from the indicator description.
"", Prefer text after ' — ' (e.g. 'RSI 68.2 — Overbought' → 'Overbought').
"═" * WIDTH, Fall back to the signal enum name.
" ╔═══════════════════════════════════════════════════════╗", """
" ║ FOREX SIGNAL INTELLIGENCE PLATFORM ║", desc = ind.description
" ║ Multi-Layer Signal Analysis Engine ║", if " — " in desc:
" ╚═══════════════════════════════════════════════════════╝", return desc.split(" — ", 1)[1].strip()
f" Scan Time: {now} TF: {timeframe}", if " | " in desc:
"═" * WIDTH, # e.g. 'MACD bullish crossover | Hist: 0.00008' — take first clause
"", return desc.split(" | ", 1)[0].strip()
] return ind.signal.name.replace("_", " ").title()
return "\n".join(lines)
def _format_table(reports: List[SignalReport]) -> str: def _fmt_float(val: float) -> str:
lines = [ """Human-readable float with appropriate precision."""
" ┌─────────────┬───────────┬──────────────────────────────────┬────────┬────────────┐", if val == 0.0:
" │ Pair │ Signal │ Score │ Grade │ Confidence │", return "0"
" ├─────────────┼───────────┼──────────────────────────────────┼────────┼────────────┤", av = abs(val)
] if av >= 1000:
for r in reports: return f"{val:.1f}"
sig = r.final_signal.name.replace("_", " ") if av >= 10:
bar = _score_bar(r.final_score, 30) return f"{val:.2f}"
pair = f"{r.display_name:<11}" if av >= 0.01:
signal_str = f"{sig:<9}" return f"{val:.4f}"
grade_str = f"{r.signal_quality:<6}" return f"{val:.6f}"
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} │") def _h(text: str) -> str:
"""HTML-escape a string for safe use inside HTML parse mode."""
return html.escape(str(text))
def _build_alert(report: SignalReport, timeframe: str) -> str:
"""
Build a single trade alert card using the required visual template.
Returns an HTML-formatted string ready for Telegram.
Template:
{Dot} **TRADE ALERT: {ACTION} {PAIR}**
Grade / confidence / timeframe subtitle
**Entry:** {price}
**Take Profit:** {price} (+pips)
**Stop Loss:** {price} (-pips)
**Risk:Reward:** 1:2.0
**Technical Indicators:**
• {Name}: {Value} ({Condition})
...
Multi-Timeframe block
Warnings / events / news
Market context line
Disclaimer
"""
dot = _grade_dot(report.signal_quality)
action = _signal_action(report.final_signal)
pair = _h(report.display_name)
grade = _h(report.signal_quality)
conf = report.final_confidence
lines: List[str] = []
# ── Header ───────────────────────────────────────────────────────
lines.append(f"{dot} <b>TRADE ALERT: {action} {pair}</b>")
lines.append(f"<i>Grade: {grade} | Confidence: {conf:.0f}% | {timeframe}</i>")
lines.append("")
# ── Trade setup (only when grade B or above) ─────────────────────
if report.trade_setup:
ts = report.trade_setup
lines.append(f"<b>Entry:</b> {ts.entry_price:.5f}")
lines.append( lines.append(
" └─────────────┴───────────┴──────────────────────────────────┴────────┴────────────┘" f"<b>Take Profit:</b> {ts.take_profit_1:.5f}"
f" <i>(+{ts.reward_pips:.1f} pips)</i>"
) )
lines += [ if abs(ts.take_profit_2 - ts.take_profit_1) > 1e-8:
"", lines.append(f"<b>Take Profit 2:</b> {ts.take_profit_2:.5f}")
" Legend: ██ Bullish | ██ Bearish | │ Center (0) | ⚠ Event Risk | △ Caution", lines.append(
] f"<b>Stop Loss:</b> {ts.stop_loss:.5f}"
return "\n".join(lines) f" <i>(-{ts.risk_pips:.1f} pips)</i>"
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( lines.append(f"<b>Risk:Reward:</b> 1:{ts.risk_reward:.1f}")
f"\n SIGNAL: {sig_name} │ Score: {r.final_score:+.1f}" else:
f" │ Grade: {r.signal_quality} │ Confidence: {r.final_confidence:.0f}%" # No actionable setup — show price and recommendation
) lines.append(f"<b>Current Price:</b> {report.current_price:.5f}")
p1.append(f" Action: {r.action_recommendation}") lines.append(f"<b>Analysis:</b> {_h(report.action_recommendation)}")
if r.mtf_signals: lines.append("")
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: # ── Technical Indicators ─────────────────────────────────────────
p1.append(f"\n Indicators ({r.primary_analysis.timeframe}):") if report.primary_analysis and report.primary_analysis.indicators:
for name, ind in r.primary_analysis.indicators.items(): lines.append("<b>Technical Indicators:</b>")
for name, ind in report.primary_analysis.indicators.items():
if name.startswith("_") or ind.weight == 0: if name.startswith("_") or ind.weight == 0:
continue continue
ind_name = f"{ind.name:<18}" val_str = _fmt_float(ind.value)
sig_str = f"{ind.signal.name.replace('_', ' '):<12}" condition = _h(_indicator_condition(ind))
desc = ind.description[:60] ind_name = _h(ind.name)
p1.append(f" {ind_name} {sig_str} ({ind.confidence:.0f}%) │ {desc}") lines.append(f"• {ind_name}: {val_str} ({condition})")
# ── Part 2: reasons, warnings, trade setup, key levels ───────── # ── Multi-Timeframe ───────────────────────────────────────────────
p2 = [] if report.mtf_signals:
lines.append("")
agree_str = "✅ Aligned" if report.mtf_agreement else "⚠️ Divergent"
lines.append(f"<b>Multi-Timeframe ({agree_str}):</b>")
for tf, sig in report.mtf_signals.items():
score = report.mtf_scores.get(tf, 0)
sig_name = _h(sig.name.replace("_", " "))
lines.append(f" {_h(tf)}: {sig_name} ({score:+.0f})")
if r.bullish_reasons: # ── Warnings ─────────────────────────────────────────────────────
p2.append(" ▲ Bullish Factors:") if report.warnings:
for reason in r.bullish_reasons[:5]: lines.append("")
p2.append(f" + {reason}") for w in report.warnings:
lines.append(f"⚠️ {_h(w)}")
if r.bearish_reasons: # ── Calendar / news ───────────────────────────────────────────────
p2.append("\n ▼ Bearish Factors:") if report.event_risk != "clear" and report.event_description:
for reason in r.bearish_reasons[:5]: lines.append(f"📅 <b>Event Risk:</b> {_h(report.event_description)}")
p2.append(f" - {reason}")
if r.warnings: if report.news_description and report.news_description != "Neutral news sentiment":
p2.append("\n ⚠ Warnings:") lines.append(f"📰 {_h(report.news_description)}")
for w in r.warnings:
p2.append(f" ! {w}")
if r.news_description and r.news_description != "Neutral news sentiment": # ── Context footer ────────────────────────────────────────────────
p2.append(f"\n 📰 News: {r.news_description}") lines.append("")
if r.event_description and r.event_risk != "clear": lines.append(
p2.append(f" 📅 Event: {r.event_description}") f"<i>Regime: {_h(report.market_regime)} | "
f"Vol: {_h(report.volatility_regime)} | "
f"Score: {report.final_score:+.1f}</i>"
)
lines.append("<i>⚠️ Educational use only. Not financial advice.</i>")
if r.trade_setup: return "\n".join(lines)
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 ───────────────────────────────────────────────── # ── Per-chat state ──────────────────────────────────────────────────
class ChatState: class ChatState:
def __init__(self): def __init__(self):
self.timeframe: str = "H1" self.timeframe: str = "H1"
# ══════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════
# BOT # Bot
# ══════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════
class ForexTelegramBot: class ForexTelegramBot:
def __init__(self): def __init__(self):
self._states: Dict[int, ChatState] = {} self._states: Dict[int, ChatState] = {}
self._executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="forex") self._executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="forex")
# ── Helpers ───────────────────────────────────────────────────────
def _state(self, chat_id: int) -> ChatState: def _state(self, chat_id: int) -> ChatState:
if chat_id not in self._states: if chat_id not in self._states:
self._states[chat_id] = ChatState() self._states[chat_id] = ChatState()
return self._states[chat_id] return self._states[chat_id]
def _build_config(self, tf: str, pairs: list = None) -> PlatformConfig: def _build_config(self, tf: str, pairs: Optional[List[str]] = None) -> PlatformConfig:
config = PlatformConfig() cfg = PlatformConfig()
config.primary_timeframe = TF_MAP[tf] cfg.primary_timeframe = TF_MAP[tf]
config.confirmation_timeframes = TF_CONFIRMATIONS.get(tf, [Timeframe.M15, Timeframe.H4]) cfg.confirmation_timeframes = TF_CONFIRMATIONS.get(tf, [Timeframe.M15, Timeframe.H4])
config.watch_pairs = list(pairs or MAJOR_PAIRS) cfg.watch_pairs = list(pairs or MAJOR_PAIRS)
return config return cfg
def _run_scan(self, tf: str, pairs: list) -> List[SignalReport]: def _run_scan(self, tf: str, pairs: List[str]) -> List[SignalReport]:
return SignalAggregator(self._build_config(tf, pairs)).analyze_all_pairs() return SignalAggregator(self._build_config(tf, pairs)).analyze_all_pairs()
async def _analyze(self, func, *args): async def _analyze(self, func, *args):
@@ -248,33 +252,30 @@ class ForexTelegramBot:
return await loop.run_in_executor(self._executor, func, *args) return await loop.run_in_executor(self._executor, func, *args)
@staticmethod @staticmethod
def _parse_tf(args: list) -> Optional[str]: def _parse_tf(args: List[str]) -> Optional[str]:
for a in args: for a in args:
if a.upper() in TF_MAP: if a.upper() in TF_MAP:
return a.upper() return a.upper()
return None return None
@staticmethod @staticmethod
async def _send_pre(update: Update, text: str): async def _send_html(update: Update, text: str):
"""Send text as a monospace pre block, splitting if over 4096 chars.""" """Send HTML message, splitting at newlines if over Telegram's 4096-char limit."""
LIMIT = 3800 # leave room for <pre> tags LIMIT = 4000
chunks = [] chunks: List[str] = []
while text: while text:
if len(text) <= LIMIT: if len(text) <= LIMIT:
chunks.append(text) chunks.append(text)
break break
# Split at last newline before limit
split_at = text.rfind("\n", 0, LIMIT) split_at = text.rfind("\n", 0, LIMIT)
if split_at == -1: if split_at == -1:
split_at = LIMIT split_at = LIMIT
chunks.append(text[:split_at]) chunks.append(text[:split_at])
text = text[split_at:].lstrip("\n") text = text[split_at:].lstrip("\n")
for chunk in chunks: for chunk in chunks:
await update.message.reply_text( await update.message.reply_text(chunk, parse_mode=ParseMode.HTML)
f"<pre>{chunk}</pre>", parse_mode=ParseMode.HTML
)
# ── Commands ─────────────────────────────────────────────────── # ── Commands ──────────────────────────────────────────────────────
async def cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
await self.cmd_help(update, context) await self.cmd_help(update, context)
@@ -282,22 +283,26 @@ class ForexTelegramBot:
async def cmd_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def cmd_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
state = self._state(update.effective_chat.id) state = self._state(update.effective_chat.id)
text = ( text = (
"<b>🌐 Forex Signal Intelligence Platform</b>\n\n" "🌐 <b>Forex Signal Intelligence Platform</b>\n\n"
"<b>Commands:</b>\n" "<b>Commands:</b>\n"
" /scan — Scan all major pairs\n" " /signal EURUSD — Signal card for a pair\n"
" /scan H4 — Scan with specific timeframe\n" " /signal EURUSD H4 — With specific timeframe\n"
" /pair EURUSD — Detailed analysis\n" " /scan — Scan all pairs (Grade B+ alerts only)\n"
" /pair EURUSD H4 — With specific timeframe\n" " /scan H4 — Scan at a specific timeframe\n"
" /tf H1 — Set default timeframe\n" " /tf H1 — Set your default timeframe\n"
" /help — This message\n\n" " /help — This message\n\n"
"<b>Timeframes:</b>\n" "<b>Timeframes:</b>\n"
" <code>M5 </code> — 5 minutes\n" " <code>M5 </code> — 5 minutes\n"
" <code>M15</code> — 15 minutes\n" " <code>M15</code> — 15 minutes\n"
" <code>M30</code> — 30 minutes\n" " <code>M30</code> — 30 minutes\n"
" <code>H1 </code> — 1 hour ← recommended for intraday\n" " <code>H1 </code> — 1 hour (default, intraday)\n"
" <code>H4 </code> — 4 hours ← recommended for swing trading\n" " <code>H4 </code> — 4 hours (swing trading)\n"
" <code>D1 </code> — Daily ← recommended for position trading\n\n" " <code>D1 </code> — Daily (position trading)\n\n"
f"Current default: <b>{state.timeframe}</b>\n\n" "<b>Signal Grades:</b>\n"
" 🟢 A / A+ — High confidence, consider entry\n"
" 🟡 B — Decent setup, wait for confirmation\n"
" 🔴 C / D — Low confidence, watch only\n\n"
f"Your default timeframe: <b>{state.timeframe}</b>\n\n"
"<i>⚠️ Educational use only. Not financial advice.</i>" "<i>⚠️ Educational use only. Not financial advice.</i>"
) )
await update.message.reply_text(text, parse_mode=ParseMode.HTML) await update.message.reply_text(text, parse_mode=ParseMode.HTML)
@@ -307,65 +312,29 @@ class ForexTelegramBot:
if not tf: if not tf:
opts = " ".join(TF_MAP.keys()) opts = " ".join(TF_MAP.keys())
await update.message.reply_text( await update.message.reply_text(
f"Usage: <b>/tf &lt;timeframe&gt;</b>\n" f"Usage: <b>/tf &lt;timeframe&gt;</b>\nOptions: <code>{opts}</code>",
f"Options: <code>{opts}</code>",
parse_mode=ParseMode.HTML, parse_mode=ParseMode.HTML,
) )
return return
self._state(update.effective_chat.id).timeframe = tf self._state(update.effective_chat.id).timeframe = tf
await update.message.reply_text( await update.message.reply_text(
f"✅ Default timeframe set to <b>{tf}</b>", parse_mode=ParseMode.HTML f"✅ Default timeframe set to <b>{tf}</b>",
parse_mode=ParseMode.HTML,
) )
async def cmd_scan(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def cmd_signal(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
chat_id = update.effective_chat.id """
state = self._state(chat_id) /signal EURUSD [TF]
tf = self._parse_tf(context.args or []) or state.timeframe Analyze a single pair and return the full trade alert card.
"""
msg = await update.message.reply_text(f"🔄 Scanning markets ({tf})…")
try:
reports = await self._analyze(self._run_scan, tf, list(MAJOR_PAIRS))
# ── 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)
# ── detailed block for each actionable signal ──────────
actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B")]
if actionable:
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:
await self._send_pre(
update,
"\n No high-grade signals detected.\n"
" Use /scan with a different timeframe to see more.\n",
)
except Exception as e:
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 chat_id = update.effective_chat.id
state = self._state(chat_id) state = self._state(chat_id)
args = context.args or [] args = context.args or []
if not args: if not args:
await update.message.reply_text( await update.message.reply_text(
"Usage: <b>/pair &lt;symbol&gt; [timeframe]</b>\n" "Usage: <b>/signal &lt;pair&gt; [timeframe]</b>\n"
"Example: <code>/pair EURUSD H4</code>", "Example: <code>/signal EURUSD H4</code>",
parse_mode=ParseMode.HTML, parse_mode=ParseMode.HTML,
) )
return return
@@ -374,27 +343,77 @@ class ForexTelegramBot:
if not raw.endswith("=X"): if not raw.endswith("=X"):
raw += "=X" raw += "=X"
tf = self._parse_tf(args[1:]) or state.timeframe tf = self._parse_tf(args[1:]) or state.timeframe
display = SYMBOL_DISPLAY.get(raw, raw) display = SYMBOL_DISPLAY.get(raw, raw.replace("=X", ""))
msg = await update.message.reply_text(f"🔄 Analyzing {display} ({tf})…") msg = await update.message.reply_text(f"🔄 Analyzing {_h(display)} ({tf})…")
try: try:
reports = await self._analyze(self._run_scan, tf, [raw]) reports = await self._analyze(self._run_scan, tf, [raw])
if not reports: if not reports:
await msg.edit_text(f"❌ No data returned for {raw}") await msg.edit_text(f"❌ No data returned for {_h(display)}")
return return
r = reports[0] r = reports[0]
summary = _format_header(tf) + _format_table([r]) alert = _build_alert(r, tf)
await msg.edit_text(f"<pre>{summary}</pre>", parse_mode=ParseMode.HTML) await msg.delete()
await self._send_html(update, alert)
for part in _format_detail(r):
await self._send_pre(update, part)
except Exception as e: except Exception as e:
logger.error(f"Pair error for {raw}: {e}") logger.error(f"Signal error for {raw}: {e}", exc_info=True)
await msg.edit_text(f"❌ Analysis failed: {e}") await msg.edit_text(f"❌ Analysis failed: {_h(str(e))}")
# ── Application builder ──────────────────────────────────────── async def cmd_scan(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
/scan [TF]
Analyze all major pairs and send a trade alert card for every
signal rated Grade B or above.
"""
chat_id = update.effective_chat.id
state = self._state(chat_id)
tf = self._parse_tf(context.args or []) or state.timeframe
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
msg = await update.message.reply_text(f"🔄 Scanning all pairs ({tf})…")
try:
reports = await self._analyze(self._run_scan, tf, list(MAJOR_PAIRS))
actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B")]
if not actionable:
await msg.edit_text(
f"🔍 <b>Scan complete</b> — {now_str}\n\n"
"No Grade B+ signals found at this time.\n"
"Try a different timeframe with <code>/scan H4</code>.",
parse_mode=ParseMode.HTML,
)
return
# Summary header
grades = {"A+": 0, "A": 0, "B": 0}
for r in actionable:
grades[r.signal_quality] = grades.get(r.signal_quality, 0) + 1
grade_str = " ".join(
f"{g}: {count}" for g, count in grades.items() if count
)
summary = (
f"📊 <b>Scan complete</b> — {now_str}\n"
f"Timeframe: <b>{tf}</b>\n"
f"Grade B+ signals: <b>{len(actionable)}</b> ({grade_str})\n\n"
"Trade alerts follow ↓"
)
await msg.edit_text(summary, parse_mode=ParseMode.HTML)
# One card per actionable signal
for r in actionable:
alert = _build_alert(r, tf)
await self._send_html(update, alert)
except Exception as e:
logger.error(f"Scan error: {e}", exc_info=True)
await msg.edit_text(f"❌ Scan failed: {_h(str(e))}")
# ── Application builder ───────────────────────────────────────────
def build_application(self) -> Application: def build_application(self) -> Application:
token = os.environ.get("TELEGRAM_BOT_TOKEN") token = os.environ.get("TELEGRAM_BOT_TOKEN")
@@ -402,15 +421,16 @@ class ForexTelegramBot:
raise ValueError( raise ValueError(
"TELEGRAM_BOT_TOKEN environment variable is not set.\n" "TELEGRAM_BOT_TOKEN environment variable is not set.\n"
"Get a token from @BotFather on Telegram, then:\n" "Get a token from @BotFather on Telegram, then:\n"
" export TELEGRAM_BOT_TOKEN='your_token_here'" " Windows: set TELEGRAM_BOT_TOKEN=your_token\n"
" Linux/Mac: export TELEGRAM_BOT_TOKEN=your_token"
) )
async def _register_commands(app: Application) -> None: async def _register_commands(app: Application) -> None:
await app.bot.set_my_commands([ await app.bot.set_my_commands([
BotCommand("scan", "Scan all major pairs"), BotCommand("signal", "Trade alert for one pair — /signal EURUSD"),
BotCommand("pair", "Detailed pair analysis — /pair EURUSD"), BotCommand("scan", "Scan all pairs, Grade B+ alerts — /scan H4"),
BotCommand("tf", "Set default timeframe — /tf H4"), BotCommand("tf", "Set default timeframe — /tf H4"),
BotCommand("help", "Commands and timeframe guide"), BotCommand("help", "Commands and grade guide"),
]) ])
app = ( app = (
@@ -423,13 +443,13 @@ class ForexTelegramBot:
app.add_handler(CommandHandler("start", self.cmd_start)) app.add_handler(CommandHandler("start", self.cmd_start))
app.add_handler(CommandHandler("help", self.cmd_help)) app.add_handler(CommandHandler("help", self.cmd_help))
app.add_handler(CommandHandler("tf", self.cmd_tf)) app.add_handler(CommandHandler("tf", self.cmd_tf))
app.add_handler(CommandHandler("signal", self.cmd_signal))
app.add_handler(CommandHandler("scan", self.cmd_scan)) app.add_handler(CommandHandler("scan", self.cmd_scan))
app.add_handler(CommandHandler("pair", self.cmd_pair))
return app return app
# ══════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════
def main(): def main():
logging.basicConfig( logging.basicConfig(