Combine all TF outputs into single message, add EST price timestamp

/signal: one combined message with all 4 TF sections stacked (────── dividers),
  entry/TP/SL always preserved, indicators per TF, MTF folded to one footer line,
  disclaimer once at bottom, news/warnings only when relevant

/scan: single compact message — one header+trade line per pair, full entry/TP/SL
  kept, no indicator list (use /signal to drill in), danger events shown inline

Both commands show price pull time as EST (e.g. 09:42 AM EST · Mon 04 May 2026)
Scanning progress message no longer shows a single TF in parentheses

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Harwinder
2026-05-04 17:39:21 -04:00
parent 0ad741670f
commit f053411343
+165 -138
View File
@@ -3,7 +3,7 @@
Forex Signal Intelligence Platform - Telegram Bot Forex Signal Intelligence Platform - Telegram Bot
================================================== ==================================================
Trade alert frontend for the signal engine. Trade alert frontend for the signal engine.
Analyzes all timeframes automatically — no TF parameter needed. All timeframes are analyzed automatically — no TF parameter needed.
Setup: Setup:
set TELEGRAM_BOT_TOKEN=your_token_here (Windows CMD) set TELEGRAM_BOT_TOKEN=your_token_here (Windows CMD)
@@ -11,8 +11,8 @@ Setup:
python telegram_bot.py python telegram_bot.py
Commands: Commands:
/signal EURUSD — Signal cards for one pair across all timeframes /signal EURUSD — Combined signal card across all timeframes
/scan — Scan all pairs, show Grade B+ alerts /scan — Scan all pairs, compact Grade B+ summary
/help — Command guide /help — Command guide
""" """
@@ -21,8 +21,9 @@ import html
import logging import logging
import os import os
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone from datetime import datetime
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from zoneinfo import ZoneInfo
from telegram import BotCommand, Update from telegram import BotCommand, Update
from telegram.constants import ParseMode from telegram.constants import ParseMode
@@ -36,8 +37,8 @@ from signal_aggregator import SignalAggregator, SignalReport
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── Timeframes run on every analysis ─────────────────────────────── # ── Config ──────────────────────────────────────────────────────────
# Each TF is run as the primary; its confirmation TFs are auto-selected.
SCAN_TIMEFRAMES = ["M15", "H1", "H4", "D1"] SCAN_TIMEFRAMES = ["M15", "H1", "H4", "D1"]
_TF_MAP = { _TF_MAP = {
@@ -54,11 +55,15 @@ _TF_CONFIRMATIONS = {
} }
_SUPPORTED_PAIRS = set(MAJOR_PAIRS + CROSS_PAIRS) _SUPPORTED_PAIRS = set(MAJOR_PAIRS + CROSS_PAIRS)
_GRADE_RANK = {"A+": 0, "A": 1, "B": 2, "C": 3, "D": 4, "F": 5}
_GRADE_RANK = {"A+": 0, "A": 1, "B": 2, "C": 3, "D": 4, "F": 5} _EST = ZoneInfo("America/New_York")
# ── Formatting helpers ────────────────────────────────────────────── # ── Formatting helpers ──────────────────────────────────────────────
def _now_est() -> str:
return datetime.now(_EST).strftime("%I:%M %p EST · %a %d %b %Y")
def _grade_dot(quality: str) -> str: def _grade_dot(quality: str) -> str:
if quality in ("A+", "A"): if quality in ("A+", "A"):
return "🟢" return "🟢"
@@ -103,85 +108,145 @@ def _h(text: str) -> str:
return html.escape(str(text)) return html.escape(str(text))
def _build_alert(report: SignalReport, timeframe: str) -> str: # ── Message builders ────────────────────────────────────────────────
"""Build one trade alert card (HTML) for a given report + timeframe."""
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
def _build_combined_signal(
results_by_tf: Dict[str, List[SignalReport]],
display: str,
now_str: str,
synthetic: bool = False,
) -> str:
"""
Single message showing all timeframes stacked.
Entry/TP/SL always preserved; trimmed: per-card disclaimer,
MTF detail block (folded to one line), news unless non-neutral.
"""
lines: List[str] = [] lines: List[str] = []
lines.append(f"🌐 <b>{_h(display)} — Signal Analysis</b>")
lines.append(f"🕐 {now_str}")
# ── Header ─────────────────────────────────────────────────────── if synthetic:
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 (Grade B and above only) ─────────────────────────
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}" "⚠️ <b>Note:</b> Not on Twelve Data free tier — "
f" <i>(+{ts.reward_pips:.1f} pips)</i>" "prices are <b>simulated</b> data."
) )
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}") for tf in SCAN_TIMEFRAMES:
reports = results_by_tf.get(tf, [])
if not reports:
continue
r = reports[0]
dot = _grade_dot(r.signal_quality)
action = _signal_action(r.final_signal)
lines.append("")
lines.append("────────────────────────")
lines.append( lines.append(
f"<b>Stop Loss:</b> {ts.stop_loss:.5f}" f"{dot} <b>{tf} · {action} · "
f" <i>(-{ts.risk_pips:.1f} pips)</i>" f"Grade {_h(r.signal_quality)} · {r.final_confidence:.0f}%</b>"
) )
lines.append(f"<b>Risk:Reward:</b> 1:{ts.risk_reward:.1f}") lines.append("")
else:
lines.append(f"<b>Current Price:</b> {report.current_price:.5f}") # ── Trade setup (always kept intact) ─────────────────────────
lines.append(f"<b>Analysis:</b> {_h(report.action_recommendation)}") if r.trade_setup:
ts = r.trade_setup
lines.append(f"<b>Entry:</b> {ts.entry_price:.5f}")
lines.append(
f"<b>Take Profit:</b> {ts.take_profit_1:.5f}"
f" <i>(+{ts.reward_pips:.1f} pips)</i>"
)
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}")
lines.append(
f"<b>Stop Loss:</b> {ts.stop_loss:.5f}"
f" <i>(-{ts.risk_pips:.1f} pips)</i>"
)
lines.append(f"<b>Risk:Reward:</b> 1:{ts.risk_reward:.1f}")
lines.append("")
else:
lines.append(f"<b>Current Price:</b> {r.current_price:.5f}")
lines.append(f"<b>Analysis:</b> {_h(r.action_recommendation)}")
lines.append("")
# ── Indicators ────────────────────────────────────────────────
if r.primary_analysis and r.primary_analysis.indicators:
for name, ind in r.primary_analysis.indicators.items():
if name.startswith("_") or ind.weight == 0:
continue
lines.append(
f"• {_h(ind.name)}: {_fmt_float(ind.value)}"
f" ({_h(_indicator_condition(ind))})"
)
# ── MTF summary (one line) ────────────────────────────────────
agree_str = "✅ Aligned" if r.mtf_agreement else "⚠️ Divergent"
lines.append(
f"<i>MTF: {agree_str} | Score: {r.final_score:+.1f}"
f" | {_h(r.market_regime)} | Vol: {_h(r.volatility_regime)}</i>"
)
# ── Critical warnings only ────────────────────────────────────
for w in r.warnings:
if any(k in w for k in ("EVENT RISK", "CONFLICT", "EXTREME")):
lines.append(f"⚠️ {_h(w)}")
# ── Event / news only when relevant ──────────────────────────
if r.event_risk == "danger" and r.event_description:
lines.append(f"📅 <b>Event Risk:</b> {_h(r.event_description)}")
if r.news_description and r.news_description not in ("", "Neutral news sentiment"):
lines.append(f"📰 {_h(r.news_description)}")
lines.append("") lines.append("")
# ── Technical Indicators ─────────────────────────────────────────
if report.primary_analysis and report.primary_analysis.indicators:
lines.append("<b>Technical Indicators:</b>")
for name, ind in report.primary_analysis.indicators.items():
if name.startswith("_") or ind.weight == 0:
continue
val_str = _fmt_float(ind.value)
condition = _h(_indicator_condition(ind))
ind_name = _h(ind.name)
lines.append(f"• {ind_name}: {val_str} ({condition})")
# ── Multi-Timeframe ───────────────────────────────────────────────
if report.mtf_signals:
lines.append("")
agree_str = "✅ Aligned" if report.mtf_agreement else "⚠️ Divergent"
lines.append(f"<b>Confirmation TFs ({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})")
# ── Warnings ─────────────────────────────────────────────────────
if report.warnings:
lines.append("")
for w in report.warnings:
lines.append(f"⚠️ {_h(w)}")
# ── Calendar / news ───────────────────────────────────────────────
if report.event_risk != "clear" and report.event_description:
lines.append(f"📅 <b>Event Risk:</b> {_h(report.event_description)}")
if report.news_description and report.news_description != "Neutral news sentiment":
lines.append(f"📰 {_h(report.news_description)}")
# ── Context footer ────────────────────────────────────────────────
lines.append("")
lines.append(
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>") lines.append("<i>⚠️ Educational use only. Not financial advice.</i>")
return "\n".join(lines)
def _build_scan_message(
actionable: List[Tuple[str, SignalReport]],
now_str: str,
tfs_str: str,
) -> str:
"""
Compact single message: one line header + one trade line per pair.
Full entry/TP/SL preserved; no indicator list (use /signal to drill in).
"""
lines: List[str] = []
lines.append(f"📊 <b>Scan Complete</b> · {now_str}")
lines.append(f"Timeframes: <code>{_h(tfs_str)}</code> | Best signal per pair")
lines.append("")
for tf, r in actionable:
dot = _grade_dot(r.signal_quality)
action = _signal_action(r.final_signal)
lines.append(
f"{dot} <b>{_h(r.display_name)}</b> · {action}"
f" · {_h(r.signal_quality)} · {tf} · {r.final_confidence:.0f}%"
)
if r.trade_setup:
ts = r.trade_setup
lines.append(
f" Entry: {ts.entry_price:.5f} | "
f"TP: {ts.take_profit_1:.5f} (+{ts.reward_pips:.0f}p) | "
f"SL: {ts.stop_loss:.5f} (-{ts.risk_pips:.0f}p) | "
f"R:R 1:{ts.risk_reward:.1f}"
)
else:
lines.append(" Watch only")
# Show danger-level events inline
if r.event_risk == "danger" and r.event_description:
lines.append(f" 📅 {_h(r.event_description)}")
lines.append("")
if not actionable:
lines.append("No Grade B+ signals found across any timeframe.")
lines.append("")
lines.append("<i>⚠️ Educational use only. Not financial advice.</i>")
return "\n".join(lines) return "\n".join(lines)
@@ -191,7 +256,6 @@ def _build_alert(report: SignalReport, timeframe: str) -> str:
class ForexTelegramBot: class ForexTelegramBot:
def __init__(self): def __init__(self):
# One worker per timeframe so all TFs run concurrently
self._executor = ThreadPoolExecutor( self._executor = ThreadPoolExecutor(
max_workers=len(SCAN_TIMEFRAMES), thread_name_prefix="forex" max_workers=len(SCAN_TIMEFRAMES), thread_name_prefix="forex"
) )
@@ -228,7 +292,7 @@ class ForexTelegramBot:
@staticmethod @staticmethod
async def _send_html(update: Update, text: str): async def _send_html(update: Update, text: str):
"""Send HTML, splitting into chunks if over 4096 chars.""" """Send HTML, splitting into chunks at newlines if over 4096 chars."""
LIMIT = 4000 LIMIT = 4000
chunks: List[str] = [] chunks: List[str] = []
while text: while text:
@@ -253,10 +317,10 @@ class ForexTelegramBot:
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"
" /signal EURUSD — Signal cards for all timeframes\n" " /signal EURUSD — Full signal analysis across all timeframes\n"
" /scan — Scan all pairs, Grade B+ alerts\n" " /scan — Scan all pairs, compact Grade B+ summary\n"
" /help — This message\n\n" " /help — This message\n\n"
f"<b>Timeframes analyzed automatically:</b> <code>{tfs}</code>\n\n" f"<b>Timeframes analyzed:</b> <code>{tfs}</code>\n\n"
"<b>Signal Grades:</b>\n" "<b>Signal Grades:</b>\n"
" 🟢 A / A+ — High confidence, consider entry\n" " 🟢 A / A+ — High confidence, consider entry\n"
" 🟡 B — Decent setup, wait for confirmation\n" " 🟡 B — Decent setup, wait for confirmation\n"
@@ -268,8 +332,8 @@ class ForexTelegramBot:
async def cmd_signal(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def cmd_signal(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
""" """
/signal EURUSD /signal EURUSD
Runs the pair through all timeframes concurrently and sends Runs all timeframes concurrently, returns a single combined message
one trade alert card per timeframe. with one stacked section per TF. Entry/TP/SL always preserved.
""" """
args = context.args or [] args = context.args or []
if not args: if not args:
@@ -283,38 +347,25 @@ class ForexTelegramBot:
raw = args[0].upper().replace("/", "").replace("_", "") raw = args[0].upper().replace("/", "").replace("_", "")
if not raw.endswith("=X"): if not raw.endswith("=X"):
raw += "=X" raw += "=X"
display = SYMBOL_DISPLAY.get(raw, raw.replace("=X", "")) display = SYMBOL_DISPLAY.get(raw, raw.replace("=X", ""))
tfs_str = " · ".join(SCAN_TIMEFRAMES) tfs_str = " · ".join(SCAN_TIMEFRAMES)
synthetic = raw not in _SUPPORTED_PAIRS
synthetic_warning = ""
if raw not in _SUPPORTED_PAIRS:
synthetic_warning = (
"\n\n⚠️ <b>Synthetic data warning:</b> "
f"{_h(display)} is not on the Twelve Data free tier. "
"Prices and indicators are <b>simulated</b> — not live market data."
)
msg = await update.message.reply_text( msg = await update.message.reply_text(
f"🔄 Analyzing {_h(display)} across {_h(tfs_str)}…" f"🔄 Analyzing {_h(display)} across {_h(tfs_str)}…"
) )
try: try:
results_by_tf = await self._analyze_all_tfs([raw]) results_by_tf = await self._analyze_all_tfs([raw])
now_str = _now_est()
# Check we got at least one result
if not any(results_by_tf.values()):
await msg.edit_text(f"❌ No data returned for {_h(display)}")
return
text = _build_combined_signal(results_by_tf, display, now_str, synthetic)
await msg.delete() await msg.delete()
await self._send_html(update, text)
sent = 0
for tf in SCAN_TIMEFRAMES:
reports = results_by_tf.get(tf, [])
if not reports:
continue
alert = _build_alert(reports[0], tf) + synthetic_warning
await self._send_html(update, alert)
sent += 1
if sent == 0:
await update.message.reply_text(
f"❌ No data returned for {_h(display)}"
)
except Exception as e: except Exception as e:
logger.error(f"Signal error for {raw}: {e}", exc_info=True) logger.error(f"Signal error for {raw}: {e}", exc_info=True)
@@ -324,10 +375,8 @@ class ForexTelegramBot:
""" """
/scan /scan
Analyzes all major pairs across all timeframes concurrently. Analyzes all major pairs across all timeframes concurrently.
For each pair, picks the highest-grade result across all TFs Picks the best-grade result per pair, returns a single compact message.
and sends a card for Grade B and above.
""" """
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
tfs_str = " · ".join(SCAN_TIMEFRAMES) tfs_str = " · ".join(SCAN_TIMEFRAMES)
msg = await update.message.reply_text( msg = await update.message.reply_text(
@@ -335,9 +384,9 @@ class ForexTelegramBot:
) )
try: try:
results_by_tf = await self._analyze_all_tfs(list(MAJOR_PAIRS)) results_by_tf = await self._analyze_all_tfs(list(MAJOR_PAIRS))
now_str = _now_est()
# For each pair pick the best grade across all TFs # Best grade per pair across all TFs
# best_per_pair: {symbol: (tf, report)}
best_per_pair: Dict[str, Tuple[str, SignalReport]] = {} best_per_pair: Dict[str, Tuple[str, SignalReport]] = {}
for tf, reports in results_by_tf.items(): for tf, reports in results_by_tf.items():
for r in reports: for r in reports:
@@ -348,7 +397,7 @@ class ForexTelegramBot:
): ):
best_per_pair[r.symbol] = (tf, r) best_per_pair[r.symbol] = (tf, r)
# Filter to B+ and sort best-first # Filter B+ and sort best-first
actionable = [ actionable = [
(tf, r) (tf, r)
for tf, r in best_per_pair.values() for tf, r in best_per_pair.values()
@@ -356,30 +405,8 @@ class ForexTelegramBot:
] ]
actionable.sort(key=lambda x: _GRADE_RANK.get(x[1].signal_quality, 99)) actionable.sort(key=lambda x: _GRADE_RANK.get(x[1].signal_quality, 99))
if not actionable: text = _build_scan_message(actionable, now_str, tfs_str)
await msg.edit_text( await msg.edit_text(text, parse_mode=ParseMode.HTML)
f"🔍 <b>Scan complete</b> — {now_str}\n\n"
"No Grade B+ signals found across any timeframe right now.",
parse_mode=ParseMode.HTML,
)
return
# Tally grades
grade_counts: Dict[str, int] = {}
for _, r in actionable:
grade_counts[r.signal_quality] = grade_counts.get(r.signal_quality, 0) + 1
grade_str = " ".join(f"{g}: {n}" for g, n in grade_counts.items())
summary = (
f"📊 <b>Scan complete</b> — {now_str}\n"
f"Timeframes: <code>{_h(tfs_str)}</code>\n"
f"Grade B+ signals: <b>{len(actionable)}</b> ({grade_str})\n\n"
"Best signal per pair ↓"
)
await msg.edit_text(summary, parse_mode=ParseMode.HTML)
for tf, r in actionable:
await self._send_html(update, _build_alert(r, tf))
except Exception as e: except Exception as e:
logger.error(f"Scan error: {e}", exc_info=True) logger.error(f"Scan error: {e}", exc_info=True)
@@ -399,7 +426,7 @@ class ForexTelegramBot:
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("signal", "Signal cards for one pair — /signal EURUSD"), BotCommand("signal", "Signal analysis for one pair — /signal EURUSD"),
BotCommand("scan", "Scan all pairs, best Grade B+ per pair"), BotCommand("scan", "Scan all pairs, best Grade B+ per pair"),
BotCommand("help", "Commands and grade guide"), BotCommand("help", "Commands and grade guide"),
]) ])