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:
+165
-138
@@ -3,7 +3,7 @@
|
||||
Forex Signal Intelligence Platform - Telegram Bot
|
||||
==================================================
|
||||
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:
|
||||
set TELEGRAM_BOT_TOKEN=your_token_here (Windows CMD)
|
||||
@@ -11,8 +11,8 @@ Setup:
|
||||
python telegram_bot.py
|
||||
|
||||
Commands:
|
||||
/signal EURUSD — Signal cards for one pair across all timeframes
|
||||
/scan — Scan all pairs, show Grade B+ alerts
|
||||
/signal EURUSD — Combined signal card across all timeframes
|
||||
/scan — Scan all pairs, compact Grade B+ summary
|
||||
/help — Command guide
|
||||
"""
|
||||
|
||||
@@ -21,8 +21,9 @@ import html
|
||||
import logging
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from telegram import BotCommand, Update
|
||||
from telegram.constants import ParseMode
|
||||
@@ -36,8 +37,8 @@ from signal_aggregator import SignalAggregator, SignalReport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Timeframes run on every analysis ───────────────────────────────
|
||||
# Each TF is run as the primary; its confirmation TFs are auto-selected.
|
||||
# ── Config ──────────────────────────────────────────────────────────
|
||||
|
||||
SCAN_TIMEFRAMES = ["M15", "H1", "H4", "D1"]
|
||||
|
||||
_TF_MAP = {
|
||||
@@ -54,11 +55,15 @@ _TF_CONFIRMATIONS = {
|
||||
}
|
||||
|
||||
_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 ──────────────────────────────────────────────
|
||||
|
||||
def _now_est() -> str:
|
||||
return datetime.now(_EST).strftime("%I:%M %p EST · %a %d %b %Y")
|
||||
|
||||
|
||||
def _grade_dot(quality: str) -> str:
|
||||
if quality in ("A+", "A"):
|
||||
return "🟢"
|
||||
@@ -103,85 +108,145 @@ def _h(text: str) -> str:
|
||||
return html.escape(str(text))
|
||||
|
||||
|
||||
def _build_alert(report: SignalReport, timeframe: str) -> str:
|
||||
"""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
|
||||
# ── Message builders ────────────────────────────────────────────────
|
||||
|
||||
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.append(f"🌐 <b>{_h(display)} — Signal Analysis</b>")
|
||||
lines.append(f"🕐 {now_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 (Grade B and above only) ─────────────────────────
|
||||
if report.trade_setup:
|
||||
ts = report.trade_setup
|
||||
lines.append(f"<b>Entry:</b> {ts.entry_price:.5f}")
|
||||
if synthetic:
|
||||
lines.append(
|
||||
f"<b>Take Profit:</b> {ts.take_profit_1:.5f}"
|
||||
f" <i>(+{ts.reward_pips:.1f} pips)</i>"
|
||||
"⚠️ <b>Note:</b> Not on Twelve Data free tier — "
|
||||
"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(
|
||||
f"<b>Stop Loss:</b> {ts.stop_loss:.5f}"
|
||||
f" <i>(-{ts.risk_pips:.1f} pips)</i>"
|
||||
f"{dot} <b>{tf} · {action} · "
|
||||
f"Grade {_h(r.signal_quality)} · {r.final_confidence:.0f}%</b>"
|
||||
)
|
||||
lines.append(f"<b>Risk:Reward:</b> 1:{ts.risk_reward:.1f}")
|
||||
else:
|
||||
lines.append(f"<b>Current Price:</b> {report.current_price:.5f}")
|
||||
lines.append(f"<b>Analysis:</b> {_h(report.action_recommendation)}")
|
||||
lines.append("")
|
||||
|
||||
# ── Trade setup (always kept intact) ─────────────────────────
|
||||
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("")
|
||||
|
||||
# ── 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>")
|
||||
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)
|
||||
|
||||
|
||||
@@ -191,7 +256,6 @@ def _build_alert(report: SignalReport, timeframe: str) -> str:
|
||||
|
||||
class ForexTelegramBot:
|
||||
def __init__(self):
|
||||
# One worker per timeframe so all TFs run concurrently
|
||||
self._executor = ThreadPoolExecutor(
|
||||
max_workers=len(SCAN_TIMEFRAMES), thread_name_prefix="forex"
|
||||
)
|
||||
@@ -228,7 +292,7 @@ class ForexTelegramBot:
|
||||
|
||||
@staticmethod
|
||||
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
|
||||
chunks: List[str] = []
|
||||
while text:
|
||||
@@ -253,10 +317,10 @@ class ForexTelegramBot:
|
||||
text = (
|
||||
"🌐 <b>Forex Signal Intelligence Platform</b>\n\n"
|
||||
"<b>Commands:</b>\n"
|
||||
" /signal EURUSD — Signal cards for all timeframes\n"
|
||||
" /scan — Scan all pairs, Grade B+ alerts\n"
|
||||
" /signal EURUSD — Full signal analysis across all timeframes\n"
|
||||
" /scan — Scan all pairs, compact Grade B+ summary\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"
|
||||
" 🟢 A / A+ — High confidence, consider entry\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):
|
||||
"""
|
||||
/signal EURUSD
|
||||
Runs the pair through all timeframes concurrently and sends
|
||||
one trade alert card per timeframe.
|
||||
Runs all timeframes concurrently, returns a single combined message
|
||||
with one stacked section per TF. Entry/TP/SL always preserved.
|
||||
"""
|
||||
args = context.args or []
|
||||
if not args:
|
||||
@@ -283,38 +347,25 @@ class ForexTelegramBot:
|
||||
raw = args[0].upper().replace("/", "").replace("_", "")
|
||||
if not raw.endswith("=X"):
|
||||
raw += "=X"
|
||||
display = SYMBOL_DISPLAY.get(raw, raw.replace("=X", ""))
|
||||
tfs_str = " · ".join(SCAN_TIMEFRAMES)
|
||||
|
||||
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."
|
||||
)
|
||||
display = SYMBOL_DISPLAY.get(raw, raw.replace("=X", ""))
|
||||
tfs_str = " · ".join(SCAN_TIMEFRAMES)
|
||||
synthetic = raw not in _SUPPORTED_PAIRS
|
||||
|
||||
msg = await update.message.reply_text(
|
||||
f"🔄 Analyzing {_h(display)} across {_h(tfs_str)}…"
|
||||
)
|
||||
try:
|
||||
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()
|
||||
|
||||
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)}"
|
||||
)
|
||||
await self._send_html(update, text)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Signal error for {raw}: {e}", exc_info=True)
|
||||
@@ -324,10 +375,8 @@ class ForexTelegramBot:
|
||||
"""
|
||||
/scan
|
||||
Analyzes all major pairs across all timeframes concurrently.
|
||||
For each pair, picks the highest-grade result across all TFs
|
||||
and sends a card for Grade B and above.
|
||||
Picks the best-grade result per pair, returns a single compact message.
|
||||
"""
|
||||
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
tfs_str = " · ".join(SCAN_TIMEFRAMES)
|
||||
|
||||
msg = await update.message.reply_text(
|
||||
@@ -335,9 +384,9 @@ class ForexTelegramBot:
|
||||
)
|
||||
try:
|
||||
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_per_pair: {symbol: (tf, report)}
|
||||
# Best grade per pair across all TFs
|
||||
best_per_pair: Dict[str, Tuple[str, SignalReport]] = {}
|
||||
for tf, reports in results_by_tf.items():
|
||||
for r in reports:
|
||||
@@ -348,7 +397,7 @@ class ForexTelegramBot:
|
||||
):
|
||||
best_per_pair[r.symbol] = (tf, r)
|
||||
|
||||
# Filter to B+ and sort best-first
|
||||
# Filter B+ and sort best-first
|
||||
actionable = [
|
||||
(tf, r)
|
||||
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))
|
||||
|
||||
if not actionable:
|
||||
await msg.edit_text(
|
||||
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))
|
||||
text = _build_scan_message(actionable, now_str, tfs_str)
|
||||
await msg.edit_text(text, parse_mode=ParseMode.HTML)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Scan error: {e}", exc_info=True)
|
||||
@@ -399,7 +426,7 @@ class ForexTelegramBot:
|
||||
|
||||
async def _register_commands(app: Application) -> None:
|
||||
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("help", "Commands and grade guide"),
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user