Remove TF param — analyze all timeframes automatically

- /signal EURUSD sends one card per timeframe (M15, H1, H4, D1) concurrently
- /scan analyzes all pairs across all TFs, shows best-grade card per pair (B+ only)
- All TFs run in parallel via ThreadPoolExecutor (one worker per TF)
- Remove /tf command and per-chat state — no longer needed
- Executor pool sized to len(SCAN_TIMEFRAMES) for true concurrency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Harwinder
2026-05-04 17:27:15 -04:00
parent 7bde28c524
commit 0ad741670f
+129 -159
View File
@@ -3,16 +3,17 @@
Forex Signal Intelligence Platform - Telegram Bot
==================================================
Trade alert frontend for the signal engine.
Analyzes all timeframes automatically — no TF parameter needed.
Setup:
set TELEGRAM_BOT_TOKEN=your_token_here
set TELEGRAM_BOT_TOKEN=your_token_here (Windows CMD)
export TELEGRAM_BOT_TOKEN=your_token_here (Linux/Mac)
python telegram_bot.py
Commands:
/signal EURUSD [TF] — Signal card for one pair
/scan [TF] — Scan all pairs, show Grade B+ alerts
/tf H1 — Set default timeframe
/help — Command guide
/signal EURUSD — Signal cards for one pair across all timeframes
/scan — Scan all pairs, show Grade B+ alerts
/help — Command guide
"""
import asyncio
@@ -21,44 +22,44 @@ import logging
import os
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Tuple
from telegram import BotCommand, Update
from telegram.constants import ParseMode
from telegram.ext import Application, CommandHandler, ContextTypes
from config import (
MAJOR_PAIRS, SYMBOL_DISPLAY,
MAJOR_PAIRS, CROSS_PAIRS, SYMBOL_DISPLAY,
PlatformConfig, SignalStrength, Timeframe,
)
from signal_aggregator import SignalAggregator, SignalReport
logger = logging.getLogger(__name__)
# ── Timeframe maps ──────────────────────────────────────────────────
# ── Timeframes run on every analysis ───────────────────────────────
# Each TF is run as the primary; its confirmation TFs are auto-selected.
SCAN_TIMEFRAMES = ["M15", "H1", "H4", "D1"]
TF_MAP = {
"M5": Timeframe.M5,
"M15": Timeframe.M15,
"M30": Timeframe.M30,
"H1": Timeframe.H1,
"H4": Timeframe.H4,
"D1": Timeframe.D1,
_TF_MAP = {
"M5": Timeframe.M5, "M15": Timeframe.M15,
"M30": Timeframe.M30, "H1": Timeframe.H1,
"H4": Timeframe.H4, "D1": Timeframe.D1,
}
TF_CONFIRMATIONS = {
"M5": [Timeframe.M1, Timeframe.M15],
_TF_CONFIRMATIONS = {
"M15": [Timeframe.M5, Timeframe.H1],
"M30": [Timeframe.M15, Timeframe.H1],
"H1": [Timeframe.M15, Timeframe.H4],
"H4": [Timeframe.H1, Timeframe.D1],
"D1": [Timeframe.H4, Timeframe.W1],
}
_SUPPORTED_PAIRS = set(MAJOR_PAIRS + CROSS_PAIRS)
_GRADE_RANK = {"A+": 0, "A": 1, "B": 2, "C": 3, "D": 4, "F": 5}
# ── Formatting helpers ──────────────────────────────────────────────
def _grade_dot(quality: str) -> str:
"""Colored dot matching confidence grade per spec."""
if quality in ("A+", "A"):
return "🟢"
if quality == "B":
@@ -77,22 +78,15 @@ def _signal_action(signal: SignalStrength) -> str:
def _indicator_condition(ind) -> str:
"""
Extract a short readable condition from the indicator description.
Prefer text after ' — ' (e.g. 'RSI 68.2 — Overbought' → 'Overbought').
Fall back to the signal enum name.
"""
desc = ind.description
if " — " in desc:
return desc.split(" — ", 1)[1].strip()
if " | " in desc:
# e.g. 'MACD bullish crossover | Hist: 0.00008' — take first clause
return desc.split(" | ", 1)[0].strip()
return ind.signal.name.replace("_", " ").title()
def _fmt_float(val: float) -> str:
"""Human-readable float with appropriate precision."""
if val == 0.0:
return "0"
av = abs(val)
@@ -106,33 +100,11 @@ def _fmt_float(val: float) -> str:
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
"""
"""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)
@@ -146,7 +118,7 @@ def _build_alert(report: SignalReport, timeframe: str) -> str:
lines.append(f"<i>Grade: {grade} | Confidence: {conf:.0f}% | {timeframe}</i>")
lines.append("")
# ── Trade setup (only when grade B or above) ─────────────────────
# ── 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}")
@@ -162,7 +134,6 @@ def _build_alert(report: SignalReport, timeframe: str) -> str:
)
lines.append(f"<b>Risk:Reward:</b> 1:{ts.risk_reward:.1f}")
else:
# No actionable setup — show price and recommendation
lines.append(f"<b>Current Price:</b> {report.current_price:.5f}")
lines.append(f"<b>Analysis:</b> {_h(report.action_recommendation)}")
@@ -183,7 +154,7 @@ def _build_alert(report: SignalReport, timeframe: str) -> str:
if report.mtf_signals:
lines.append("")
agree_str = "✅ Aligned" if report.mtf_agreement else "⚠️ Divergent"
lines.append(f"<b>Multi-Timeframe ({agree_str}):</b>")
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("_", " "))
@@ -214,53 +185,50 @@ def _build_alert(report: SignalReport, timeframe: str) -> str:
return "\n".join(lines)
# ── Per-chat state ──────────────────────────────────────────────────
class ChatState:
def __init__(self):
self.timeframe: str = "H1"
# ══════════════════════════════════════════════════════════════════════
# Bot
# ══════════════════════════════════════════════════════════════════════
class ForexTelegramBot:
def __init__(self):
self._states: Dict[int, ChatState] = {}
self._executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="forex")
# One worker per timeframe so all TFs run concurrently
self._executor = ThreadPoolExecutor(
max_workers=len(SCAN_TIMEFRAMES), thread_name_prefix="forex"
)
# ── Helpers ───────────────────────────────────────────────────────
# ── Engine helpers ────────────────────────────────────────────────
def _state(self, chat_id: int) -> ChatState:
if chat_id not in self._states:
self._states[chat_id] = ChatState()
return self._states[chat_id]
def _build_config(self, tf: str, pairs: Optional[List[str]] = None) -> PlatformConfig:
def _build_config(self, tf: str, pairs: List[str]) -> PlatformConfig:
cfg = PlatformConfig()
cfg.primary_timeframe = TF_MAP[tf]
cfg.confirmation_timeframes = TF_CONFIRMATIONS.get(tf, [Timeframe.M15, Timeframe.H4])
cfg.watch_pairs = list(pairs or MAJOR_PAIRS)
cfg.primary_timeframe = _TF_MAP[tf]
cfg.confirmation_timeframes = _TF_CONFIRMATIONS.get(tf, [Timeframe.M15, Timeframe.H4])
cfg.watch_pairs = list(pairs)
return cfg
def _run_scan(self, tf: str, pairs: List[str]) -> List[SignalReport]:
return SignalAggregator(self._build_config(tf, pairs)).analyze_all_pairs()
async def _analyze(self, func, *args):
async def _analyze_all_tfs(
self, pairs: List[str]
) -> Dict[str, List[SignalReport]]:
"""Run all SCAN_TIMEFRAMES concurrently, return {tf: [reports]}."""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(self._executor, func, *args)
@staticmethod
def _parse_tf(args: List[str]) -> Optional[str]:
for a in args:
if a.upper() in TF_MAP:
return a.upper()
return None
tasks = {
tf: loop.run_in_executor(self._executor, self._run_scan, tf, list(pairs))
for tf in SCAN_TIMEFRAMES
}
results: Dict[str, List[SignalReport]] = {}
for tf, task in tasks.items():
try:
results[tf] = await task
except Exception as e:
logger.error(f"TF {tf} scan failed: {e}", exc_info=True)
results[tf] = []
return results
@staticmethod
async def _send_html(update: Update, text: str):
"""Send HTML message, splitting at newlines if over Telegram's 4096-char limit."""
"""Send HTML, splitting into chunks if over 4096 chars."""
LIMIT = 4000
chunks: List[str] = []
while text:
@@ -281,60 +249,33 @@ class ForexTelegramBot:
await self.cmd_help(update, context)
async def cmd_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
state = self._state(update.effective_chat.id)
tfs = " · ".join(SCAN_TIMEFRAMES)
text = (
"🌐 <b>Forex Signal Intelligence Platform</b>\n\n"
"<b>Commands:</b>\n"
" /signal EURUSD — Signal card for a pair\n"
" /signal EURUSD H4 — With specific timeframe\n"
" /scan — Scan all pairs (Grade B+ alerts only)\n"
" /scan H4 — Scan at a specific timeframe\n"
" /tf H1 — Set your 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 (default, intraday)\n"
" <code>H4 </code> — 4 hours (swing trading)\n"
" <code>D1 </code> — Daily (position trading)\n\n"
" /signal EURUSD — Signal cards for all timeframes\n"
" /scan — Scan all pairs, Grade B+ alerts\n"
" /help — This message\n\n"
f"<b>Timeframes analyzed automatically:</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"
" 🔴 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>"
)
await update.message.reply_text(text, parse_mode=ParseMode.HTML)
async def cmd_tf(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
tf = self._parse_tf(context.args or [])
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>",
parse_mode=ParseMode.HTML,
)
return
self._state(update.effective_chat.id).timeframe = tf
await update.message.reply_text(
f"✅ Default timeframe set to <b>{tf}</b>",
parse_mode=ParseMode.HTML,
)
async def cmd_signal(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
/signal EURUSD [TF]
Analyze a single pair and return the full trade alert card.
/signal EURUSD
Runs the pair through all timeframes concurrently and sends
one trade alert card per timeframe.
"""
chat_id = update.effective_chat.id
state = self._state(chat_id)
args = context.args or []
args = context.args or []
if not args:
await update.message.reply_text(
"Usage: <b>/signal &lt;pair&gt; [timeframe]</b>\n"
"Example: <code>/signal EURUSD H4</code>",
"Usage: <b>/signal &lt;pair&gt;</b>\n"
"Example: <code>/signal EURUSD</code>",
parse_mode=ParseMode.HTML,
)
return
@@ -342,20 +283,38 @@ class ForexTelegramBot:
raw = args[0].upper().replace("/", "").replace("_", "")
if not raw.endswith("=X"):
raw += "=X"
tf = self._parse_tf(args[1:]) or state.timeframe
display = SYMBOL_DISPLAY.get(raw, raw.replace("=X", ""))
tfs_str = " · ".join(SCAN_TIMEFRAMES)
msg = await update.message.reply_text(f"🔄 Analyzing {_h(display)} ({tf})…")
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(
f"🔄 Analyzing {_h(display)} across {_h(tfs_str)}…"
)
try:
reports = await self._analyze(self._run_scan, tf, [raw])
if not reports:
await msg.edit_text(f"❌ No data returned for {_h(display)}")
return
results_by_tf = await self._analyze_all_tfs([raw])
r = reports[0]
alert = _build_alert(r, tf)
await msg.delete()
await self._send_html(update, alert)
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:
logger.error(f"Signal error for {raw}: {e}", exc_info=True)
@@ -363,51 +322,64 @@ class ForexTelegramBot:
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.
/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.
"""
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})…")
tfs_str = " · ".join(SCAN_TIMEFRAMES)
msg = await update.message.reply_text(
f"🔄 Scanning all pairs across {_h(tfs_str)}…"
)
try:
reports = await self._analyze(self._run_scan, tf, list(MAJOR_PAIRS))
results_by_tf = await self._analyze_all_tfs(list(MAJOR_PAIRS))
actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B")]
# For each pair pick the best grade across all TFs
# best_per_pair: {symbol: (tf, report)}
best_per_pair: Dict[str, Tuple[str, SignalReport]] = {}
for tf, reports in results_by_tf.items():
for r in reports:
current = best_per_pair.get(r.symbol)
if current is None or (
_GRADE_RANK.get(r.signal_quality, 99)
< _GRADE_RANK.get(current[1].signal_quality, 99)
):
best_per_pair[r.symbol] = (tf, r)
# Filter to B+ and sort best-first
actionable = [
(tf, r)
for tf, r in best_per_pair.values()
if r.signal_quality in ("A+", "A", "B")
]
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 at this time.\n"
"Try a different timeframe with <code>/scan H4</code>.",
"No Grade B+ signals found across any timeframe right now.",
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
# 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())
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"Timeframes: <code>{_h(tfs_str)}</code>\n"
f"Grade B+ signals: <b>{len(actionable)}</b> ({grade_str})\n\n"
"Trade alerts follow ↓"
"Best signal per pair ↓"
)
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)
for tf, r in actionable:
await self._send_html(update, _build_alert(r, tf))
except Exception as e:
logger.error(f"Scan error: {e}", exc_info=True)
@@ -421,15 +393,14 @@ class ForexTelegramBot:
raise ValueError(
"TELEGRAM_BOT_TOKEN environment variable is not set.\n"
"Get a token from @BotFather on Telegram, then:\n"
" Windows: set TELEGRAM_BOT_TOKEN=your_token\n"
" Linux/Mac: export TELEGRAM_BOT_TOKEN=your_token"
" Windows CMD: set TELEGRAM_BOT_TOKEN=your_token\n"
" Linux/Mac: export TELEGRAM_BOT_TOKEN=your_token"
)
async def _register_commands(app: Application) -> None:
await app.bot.set_my_commands([
BotCommand("signal", "Trade alert for one pair — /signal EURUSD"),
BotCommand("scan", "Scan all pairs, Grade B+ alerts — /scan H4"),
BotCommand("tf", "Set default timeframe — /tf H4"),
BotCommand("signal", "Signal cards for one pair — /signal EURUSD"),
BotCommand("scan", "Scan all pairs, best Grade B+ per pair"),
BotCommand("help", "Commands and grade guide"),
])
@@ -442,7 +413,6 @@ class ForexTelegramBot:
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("signal", self.cmd_signal))
app.add_handler(CommandHandler("scan", self.cmd_scan))