diff --git a/telegram_bot.py b/telegram_bot.py
index 1a784d8..2b20402 100644
--- a/telegram_bot.py
+++ b/telegram_bot.py
@@ -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"🌐 {_h(display)} — Signal Analysis")
+ lines.append(f"🕐 {now_str}")
- # ── Header ───────────────────────────────────────────────────────
- lines.append(f"{dot} TRADE ALERT: {action} {pair}")
- lines.append(f"Grade: {grade} | Confidence: {conf:.0f}% | {timeframe}")
- lines.append("")
-
- # ── Trade setup (Grade B and above only) ─────────────────────────
- if report.trade_setup:
- ts = report.trade_setup
- lines.append(f"Entry: {ts.entry_price:.5f}")
+ if synthetic:
lines.append(
- f"Take Profit: {ts.take_profit_1:.5f}"
- f" (+{ts.reward_pips:.1f} pips)"
+ "⚠️ Note: Not on Twelve Data free tier — "
+ "prices are simulated data."
)
- if abs(ts.take_profit_2 - ts.take_profit_1) > 1e-8:
- lines.append(f"Take Profit 2: {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"Stop Loss: {ts.stop_loss:.5f}"
- f" (-{ts.risk_pips:.1f} pips)"
+ f"{dot} {tf} · {action} · "
+ f"Grade {_h(r.signal_quality)} · {r.final_confidence:.0f}%"
)
- lines.append(f"Risk:Reward: 1:{ts.risk_reward:.1f}")
- else:
- lines.append(f"Current Price: {report.current_price:.5f}")
- lines.append(f"Analysis: {_h(report.action_recommendation)}")
+ lines.append("")
+
+ # ── Trade setup (always kept intact) ─────────────────────────
+ if r.trade_setup:
+ ts = r.trade_setup
+ lines.append(f"Entry: {ts.entry_price:.5f}")
+ lines.append(
+ f"Take Profit: {ts.take_profit_1:.5f}"
+ f" (+{ts.reward_pips:.1f} pips)"
+ )
+ if abs(ts.take_profit_2 - ts.take_profit_1) > 1e-8:
+ lines.append(f"Take Profit 2: {ts.take_profit_2:.5f}")
+ lines.append(
+ f"Stop Loss: {ts.stop_loss:.5f}"
+ f" (-{ts.risk_pips:.1f} pips)"
+ )
+ lines.append(f"Risk:Reward: 1:{ts.risk_reward:.1f}")
+ lines.append("")
+ else:
+ lines.append(f"Current Price: {r.current_price:.5f}")
+ lines.append(f"Analysis: {_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"MTF: {agree_str} | Score: {r.final_score:+.1f}"
+ f" | {_h(r.market_regime)} | Vol: {_h(r.volatility_regime)}"
+ )
+
+ # ── 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"📅 Event Risk: {_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("Technical Indicators:")
- 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"Confirmation TFs ({agree_str}):")
- 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"📅 Event Risk: {_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"Regime: {_h(report.market_regime)} | "
- f"Vol: {_h(report.volatility_regime)} | "
- f"Score: {report.final_score:+.1f}"
- )
lines.append("⚠️ Educational use only. Not financial advice.")
+ 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"📊 Scan Complete · {now_str}")
+ lines.append(f"Timeframes: {_h(tfs_str)} | 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} {_h(r.display_name)} · {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("⚠️ Educational use only. Not financial advice.")
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 = (
"🌐 Forex Signal Intelligence Platform\n\n"
"Commands:\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"Timeframes analyzed automatically: {tfs}\n\n"
+ f"Timeframes analyzed: {tfs}\n\n"
"Signal Grades:\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⚠️ Synthetic data warning: "
- f"{_h(display)} is not on the Twelve Data free tier. "
- "Prices and indicators are simulated — 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"🔍 Scan complete — {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"📊 Scan complete — {now_str}\n"
- f"Timeframes: {_h(tfs_str)}\n"
- f"Grade B+ signals: {len(actionable)} ({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"),
])