a708609f8b
Add EURUSD pair with dynamic pair info lookup. Telegram bot now shows 4 scan buttons: EUR/USD 15M, EUR/USD 1H, USD/JPY 15M, USD/JPY 1H. Each indicator displays directional arrows in the message output. Price formatting adapts per pair (5dp for EUR, 3dp for JPY).
272 lines
9.2 KiB
Python
272 lines
9.2 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
ForexBot2 — Telegram Bot
|
|
========================
|
|
Async Telegram interface for USD/JPY signal intelligence.
|
|
Presents a persistent Scan button; on press runs a live signal analysis
|
|
and formats the result. Trade setup (entry / SL / TP) is shown when the
|
|
engine produces a STRONG_BUY or STRONG_SELL signal.
|
|
|
|
Usage:
|
|
python telegram_bot.py
|
|
TELEGRAM_TOKEN=<token> python telegram_bot.py
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import sys
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from datetime import datetime, timezone
|
|
|
|
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
|
|
from telegram.constants import ParseMode
|
|
from telegram.ext import (
|
|
Application,
|
|
CallbackQueryHandler,
|
|
CommandHandler,
|
|
ContextTypes,
|
|
)
|
|
|
|
from config import PlatformConfig, SignalStrength, Timeframe, EURUSD, USDJPY, PAIR_INFO
|
|
from signal_aggregator import SignalAggregator, SignalReport
|
|
|
|
# ── Force UTF-8 on Windows ────────────────────────────────────────────
|
|
if hasattr(sys.stdout, "reconfigure"):
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
|
|
logging.basicConfig(
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
level=logging.INFO,
|
|
datefmt="%H:%M:%S",
|
|
)
|
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
|
logger = logging.getLogger("telegram_bot")
|
|
|
|
TOKEN = os.environ.get("TELEGRAM_TOKEN", "8228251480:AAFW2OAyQ2idMicql2t_CIXk1r9-qfRTfNE")
|
|
|
|
SCAN_BUTTONS = [
|
|
{"cb": "scan_eurusd_m15", "symbol": EURUSD, "tf": Timeframe.M15, "label": "EUR/USD 15M"},
|
|
{"cb": "scan_eurusd_h1", "symbol": EURUSD, "tf": Timeframe.H1, "label": "EUR/USD 1H"},
|
|
{"cb": "scan_usdjpy_m15", "symbol": USDJPY, "tf": Timeframe.M15, "label": "USD/JPY 15M"},
|
|
{"cb": "scan_usdjpy_h1", "symbol": USDJPY, "tf": Timeframe.H1, "label": "USD/JPY 1H"},
|
|
]
|
|
|
|
SCAN_LOOKUP = {b["cb"]: b for b in SCAN_BUTTONS}
|
|
|
|
_executor = ThreadPoolExecutor(max_workers=2)
|
|
|
|
|
|
# ── Signal formatting ─────────────────────────────────────────────────
|
|
|
|
_SIGNAL_EMOJI = {
|
|
SignalStrength.STRONG_BUY: "🟢",
|
|
SignalStrength.BUY: "🟩",
|
|
SignalStrength.NEUTRAL: "⬜",
|
|
SignalStrength.SELL: "🟥",
|
|
SignalStrength.STRONG_SELL: "🔴",
|
|
}
|
|
|
|
_QUALITY_EMOJI = {
|
|
"A+": "🏆", "A": "🥇", "B": "🥈", "C": "🥉", "D": "⚠️", "F": "❌",
|
|
}
|
|
|
|
|
|
def _signal_label(sig: SignalStrength) -> str:
|
|
return sig.name.replace("_", " ")
|
|
|
|
|
|
def _mtf_line(report: SignalReport) -> str:
|
|
if not report.mtf_signals:
|
|
return ""
|
|
parts = []
|
|
for tf, sig in report.mtf_signals.items():
|
|
score = report.mtf_scores.get(tf, 0)
|
|
parts.append(f"{tf}: {_signal_label(sig)} {score:+.1f}")
|
|
return " | ".join(parts)
|
|
|
|
|
|
_ARROW = {
|
|
SignalStrength.STRONG_BUY: "⬆️",
|
|
SignalStrength.BUY: "↗️",
|
|
SignalStrength.NEUTRAL: "➡️",
|
|
SignalStrength.SELL: "↘️",
|
|
SignalStrength.STRONG_SELL: "⬇️",
|
|
}
|
|
|
|
|
|
def _indicator_line(name: str, ind) -> str:
|
|
arrow = _ARROW.get(ind.signal, "➡️")
|
|
short = ind.description.split(" | ")[0] if " | " in ind.description else ind.description
|
|
return f"{arrow} *{ind.name}*: {short}"
|
|
|
|
|
|
def format_signal_message(report: SignalReport) -> str:
|
|
sig = report.final_signal
|
|
emoji = _SIGNAL_EMOJI.get(sig, "⬜")
|
|
qe = _QUALITY_EMOJI.get(report.signal_quality, "")
|
|
label = _signal_label(sig)
|
|
mtf_tag = "✅ MTF aligned" if report.mtf_agreement else "❌ MTF diverged"
|
|
|
|
lines: list[str] = []
|
|
|
|
# Header
|
|
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
lines += [
|
|
f"🔍 *{report.display_name} Signal Analysis* ({report.primary_analysis.timeframe if report.primary_analysis else ''})",
|
|
f"🕐 {now}",
|
|
"",
|
|
f"💰 Price: *{report.current_price:.5f}*" if report.current_price < 10 else f"💰 Price: *{report.current_price:.3f}*",
|
|
f"📊 Regime: {report.market_regime.replace('_', ' ').title()} | Vol: {report.volatility_regime}",
|
|
"",
|
|
"━━━━━━━━━━━━━━━━━━━━",
|
|
f"{emoji} *{label}* {qe}{report.signal_quality} | Score: {report.final_score:+.1f} | {report.final_confidence:.0f}% conf",
|
|
f"_{report.action_recommendation}_",
|
|
f"{mtf_tag}",
|
|
]
|
|
|
|
# MTF breakdown
|
|
mtf = _mtf_line(report)
|
|
if mtf:
|
|
lines += ["", f"📈 *MTF*: {mtf}"]
|
|
|
|
# Indicator breakdown with arrows
|
|
if report.primary_analysis:
|
|
lines += ["", "━━━━━━━━━━━━━━━━━━━━", "📋 *Indicators*"]
|
|
for name, ind in report.primary_analysis.indicators.items():
|
|
if name.startswith("_") or ind.weight == 0:
|
|
continue
|
|
lines.append(_indicator_line(name, ind))
|
|
|
|
# Warnings
|
|
if report.warnings:
|
|
lines.append("")
|
|
for w in report.warnings:
|
|
lines.append(f"⚠️ {w}")
|
|
|
|
# Trade setup — only for strong signals
|
|
if report.trade_setup and sig in (SignalStrength.STRONG_BUY, SignalStrength.STRONG_SELL):
|
|
ts = report.trade_setup
|
|
dir_emoji = "📈" if ts.direction == "BUY" else "📉"
|
|
lines += [
|
|
"",
|
|
"━━━━━━━━━━━━━━━━━━━━",
|
|
f"🚀 *TRADE SETUP*",
|
|
f"{dir_emoji} Direction: *{ts.direction}*",
|
|
f"▶ Entry: `{ts.entry_price:.3f}`",
|
|
f"🛑 SL: `{ts.stop_loss:.3f}` ({ts.risk_pips:.1f} pips)",
|
|
f"🎯 TP1: `{ts.take_profit_1:.3f}` ({ts.reward_pips:.1f} pips)",
|
|
]
|
|
if ts.take_profit_2:
|
|
lines.append(f"🎯 TP2: `{ts.take_profit_2:.3f}`")
|
|
lines.append(f"⚖️ RR: 1:{ts.risk_reward:.1f}")
|
|
|
|
lines += ["", "━━━━━━━━━━━━━━━━━━━━",
|
|
"_Educational / research use only._"]
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ── Keyboard helper ───────────────────────────────────────────────────
|
|
|
|
def scan_keyboard() -> InlineKeyboardMarkup:
|
|
return InlineKeyboardMarkup([
|
|
[InlineKeyboardButton(f"🔍 {b['label']}", callback_data=b["cb"])
|
|
for b in SCAN_BUTTONS[:2]],
|
|
[InlineKeyboardButton(f"🔍 {b['label']}", callback_data=b["cb"])
|
|
for b in SCAN_BUTTONS[2:]],
|
|
])
|
|
|
|
|
|
# ── Async signal runner (offloaded to thread pool) ────────────────────
|
|
|
|
TF_CONFIRMATIONS = {
|
|
Timeframe.M15: [Timeframe.M5, Timeframe.H1],
|
|
Timeframe.H1: [Timeframe.M15, Timeframe.H4],
|
|
}
|
|
|
|
|
|
def _run_signal_sync(symbol: str, timeframe: Timeframe) -> SignalReport:
|
|
config = PlatformConfig()
|
|
config.symbol = symbol
|
|
config.primary_timeframe = timeframe
|
|
config.confirmation_timeframes = TF_CONFIRMATIONS.get(
|
|
timeframe, [Timeframe.M15, Timeframe.H4]
|
|
)
|
|
aggregator = SignalAggregator(config)
|
|
return aggregator.analyze_pair(symbol=symbol)
|
|
|
|
|
|
async def run_signal(symbol: str, timeframe: Timeframe) -> SignalReport:
|
|
loop = asyncio.get_event_loop()
|
|
return await loop.run_in_executor(_executor, _run_signal_sync, symbol, timeframe)
|
|
|
|
|
|
# ── Handlers ──────────────────────────────────────────────────────────
|
|
|
|
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
text = (
|
|
"👋 *ForexBot2 — Signal Intelligence*\n\n"
|
|
"Pick a pair and timeframe to scan.\n"
|
|
"Strong signals include a full trade setup with entry, SL and TP."
|
|
)
|
|
await update.message.reply_text(
|
|
text,
|
|
parse_mode=ParseMode.MARKDOWN,
|
|
reply_markup=scan_keyboard(),
|
|
)
|
|
|
|
|
|
async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
query = update.callback_query
|
|
await query.answer()
|
|
|
|
btn = SCAN_LOOKUP.get(query.data)
|
|
if not btn:
|
|
return
|
|
|
|
pair_display = btn["label"]
|
|
await query.edit_message_text(
|
|
f"⏳ Scanning {pair_display}…",
|
|
parse_mode=ParseMode.MARKDOWN,
|
|
)
|
|
|
|
try:
|
|
report = await run_signal(btn["symbol"], btn["tf"])
|
|
text = format_signal_message(report)
|
|
except Exception as exc:
|
|
logger.exception("Signal scan failed")
|
|
text = f"❌ Scan failed: {exc}"
|
|
|
|
await query.edit_message_text(
|
|
text,
|
|
parse_mode=ParseMode.MARKDOWN,
|
|
reply_markup=scan_keyboard(),
|
|
)
|
|
|
|
|
|
# ── Entry point ───────────────────────────────────────────────────────
|
|
|
|
def main() -> None:
|
|
if not TOKEN:
|
|
sys.exit("TELEGRAM_TOKEN not set")
|
|
|
|
logger.info("Starting ForexBot2 Telegram bot…")
|
|
|
|
app = (
|
|
Application.builder()
|
|
.token(TOKEN)
|
|
.build()
|
|
)
|
|
|
|
app.add_handler(CommandHandler("start", cmd_start))
|
|
app.add_handler(CallbackQueryHandler(cb_scan, pattern="^scan_"))
|
|
|
|
logger.info("Bot running — press Ctrl+C to stop")
|
|
app.run_polling(allowed_updates=Update.ALL_TYPES)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|