Files
ForexBot2/telegram_bot.py
T
harwinder 4db6121ca7 Fix trade setup generation, M5 data-only mode, WS symbol limits, command menu
- Trade setup TP now scales to SL distance ensuring min RR is met when
  no viable swing target exists; skip swing targets with poor RR
- Add float tolerance to RR gate to prevent edge-case rejection
- M5 auto-scan runs for level cache only, no Telegram broadcast
- WebSocket limited to EUR/USD + XAU/USD (Grow plan constraint)
- Register bot command menu via set_my_commands on startup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 18:58:40 -04:00

803 lines
29 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
ForexBot2 — Telegram Bot
========================
Async Telegram interface with candle-boundary auto-scanner and
real-time WebSocket level-breach monitor.
Manual scan: 4 pairs × 3 timeframes (M15/H1/H4) via inline buttons.
Auto-scan: Starts automatically on boot if TELEGRAM_CHAT_ID is set.
/autoscan toggles for additional chats or to stop.
M5 fires at :00/:05/:10/… + 10s buffer (data only, no signals).
M15 fires at :00/:15/:30/:45 + 10s buffer.
H1 fires at :00 + 10s buffer.
H4 fires at :00 every 4 hours + 10s buffer.
WebSocket: Streams live ticks from TwelveData for EUR/USD & XAU/USD
(Grow plan supports only these 2 WS symbols).
When price crosses a key structure level (swing S/R, BB,
EMA200) cached from the last boundary scan, triggers an
immediate M15 scan and sends a Level Break Alert.
5-minute cooldown per pair prevents spam.
GBP/USD & USD/JPY are monitored via REST scans only.
Plan: TwelveData Grow (55 credits/min, 8 trial WS credits).
Credit budget (worst case, 4-hour boundary):
Unique fetches: 4 pairs × ~6 TFs = ~24 calls (rest cached) → fits in 55/min.
WebSocket: 2 symbols = 2 credits (one-time on connect).
Level breach scans: ~3 REST calls each, ~10/hour worst case.
Usage:
python telegram_bot.py
TELEGRAM_TOKEN=<token> TELEGRAM_CHAT_ID=<id> python telegram_bot.py
"""
import asyncio
import json
import logging
import os
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from typing import Dict, List, Optional
import websockets
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, GBPUSD, XAUUSD, PAIR_INFO,
)
from data_provider import ForexDataProvider
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")
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6025899022")
# ── Pairs & buttons ───────────────────────────────────────────────────
SCAN_PAIRS = [EURUSD, USDJPY, GBPUSD, XAUUSD]
CORRELATION_GROUPS = {
"usd_negative": {"pairs": {EURUSD, GBPUSD}, "label": "USD-weakness"},
"risk_sentiment": {"pairs": {XAUUSD, USDJPY}, "label": "Risk-sentiment"},
}
_WS_TO_PAIR = {
"EUR/USD": EURUSD,
"XAU/USD": XAUUSD,
}
_WS_SYMBOLS = list(_WS_TO_PAIR.keys())
_LEVEL_KEYS = (
"swing_high_1", "swing_high_2", "swing_high_3",
"swing_low_1", "swing_low_2", "swing_low_3",
"bb_upper", "bb_lower", "ema_trend",
)
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_eurusd_h4", "symbol": EURUSD, "tf": Timeframe.H4, "label": "EUR/USD 4H"},
{"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"},
{"cb": "scan_usdjpy_h4", "symbol": USDJPY, "tf": Timeframe.H4, "label": "USD/JPY 4H"},
{"cb": "scan_gbpusd_m15", "symbol": GBPUSD, "tf": Timeframe.M15, "label": "GBP/USD 15M"},
{"cb": "scan_gbpusd_h1", "symbol": GBPUSD, "tf": Timeframe.H1, "label": "GBP/USD 1H"},
{"cb": "scan_gbpusd_h4", "symbol": GBPUSD, "tf": Timeframe.H4, "label": "GBP/USD 4H"},
{"cb": "scan_xauusd_m15", "symbol": XAUUSD, "tf": Timeframe.M15, "label": "XAU/USD 15M"},
{"cb": "scan_xauusd_h1", "symbol": XAUUSD, "tf": Timeframe.H1, "label": "XAU/USD 1H"},
{"cb": "scan_xauusd_h4", "symbol": XAUUSD, "tf": Timeframe.H4, "label": "XAU/USD 4H"},
]
SCAN_LOOKUP = {b["cb"]: b for b in SCAN_BUTTONS}
TF_CONFIRMATIONS = {
Timeframe.M5: [Timeframe.M15, Timeframe.H1],
Timeframe.M15: [Timeframe.M5, Timeframe.H1],
Timeframe.H1: [Timeframe.M15, Timeframe.H4],
Timeframe.H4: [Timeframe.H1, Timeframe.D1],
}
AUTOSCAN_CONFIRMATIONS = {
Timeframe.M5: [Timeframe.M15, Timeframe.H1],
Timeframe.M15: [Timeframe.M5, Timeframe.H1],
Timeframe.H1: [Timeframe.M15, Timeframe.H4],
Timeframe.H4: [Timeframe.H1, Timeframe.D1],
}
_executor = ThreadPoolExecutor(max_workers=2)
# ── Shared data provider (single cache across all scans) ─────────────
_shared_dp: Optional[ForexDataProvider] = None
def _get_shared_dp() -> ForexDataProvider:
global _shared_dp
if _shared_dp is None:
_shared_dp = ForexDataProvider(PlatformConfig())
return _shared_dp
# ── Signal formatting ─────────────────────────────────────────────────
_SIGNAL_EMOJI = {
SignalStrength.STRONG_BUY: "🟢",
SignalStrength.BUY: "🟩",
SignalStrength.NEUTRAL: "⬜",
SignalStrength.SELL: "🟥",
SignalStrength.STRONG_SELL: "🔴",
}
_QUALITY_EMOJI = {
"A+": "🏆", "A": "🥇", "B": "🥈", "C": "🥉", "D": "⚠️", "F": "❌",
}
_ARROW = {
SignalStrength.STRONG_BUY: "⬆️",
SignalStrength.BUY: "↗️",
SignalStrength.NEUTRAL: "➡️",
SignalStrength.SELL: "↘️",
SignalStrength.STRONG_SELL: "⬇️",
}
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)
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] = []
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 = _mtf_line(report)
if mtf:
lines += ["", f"📈 *MTF*: {mtf}"]
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))
if report.warnings:
lines.append("")
for w in report.warnings:
lines.append(f"⚠️ {w}")
if report.trade_setup and sig not in (SignalStrength.NEUTRAL,):
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)
# ── Correlation filter ───────────────────────────────────────────────
def _detect_correlated_exposure(reports: List[SignalReport]) -> List[str]:
warnings = []
for group_info in CORRELATION_GROUPS.values():
group_pairs = group_info["pairs"]
group_label = group_info["label"]
active = [
r for r in reports
if r.symbol in group_pairs
and r.final_signal not in (SignalStrength.NEUTRAL,)
]
if len(active) >= 2:
directions = set()
for r in active:
if r.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY):
directions.add("BUY")
else:
directions.add("SELL")
if len(directions) == 1:
names = ", ".join(r.display_name for r in active)
warnings.append(
f"⚠️ Same exposure ({group_label}): {names} both {directions.pop()}"
)
return warnings
# ── Auto-scan summary format ─────────────────────────────────────────
def _format_scan_summary(reports: List[SignalReport], tf: Timeframe) -> str:
now = datetime.now(timezone.utc).strftime("%H:%M UTC")
lines = [
f"📊 *{tf.value} Auto-Scan* | {now}",
"━━━━━━━━━━━━━━━━━━━━",
]
for r in reports:
emoji = _SIGNAL_EMOJI.get(r.final_signal, "⬜")
sig = _signal_label(r.final_signal)
mtf = "✅" if r.mtf_agreement else "❌"
lines.append(
f"{emoji} *{r.display_name}* {sig} "
f"{_QUALITY_EMOJI.get(r.signal_quality, '')}{r.signal_quality} "
f"{r.final_score:+.1f} {r.final_confidence:.0f}% {mtf}MTF"
)
lines.append("━━━━━━━━━━━━━━━━━━━━")
actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B", "C")]
if actionable:
names = ", ".join(r.display_name for r in actionable)
lines.append(f"🔔 Actionable: {names}")
else:
lines.append("No actionable signals this cycle.")
corr_warnings = _detect_correlated_exposure(reports)
for w in corr_warnings:
lines.append(w)
return "\n".join(lines)
# ── Keyboard helper ───────────────────────────────────────────────────
def scan_keyboard() -> InlineKeyboardMarkup:
rows = []
for i in range(0, len(SCAN_BUTTONS), 4):
rows.append([
InlineKeyboardButton(b["label"], callback_data=b["cb"])
for b in SCAN_BUTTONS[i:i + 4]
])
return InlineKeyboardMarkup(rows)
# ── Signal runner (shared data provider for cache reuse) ──────────────
def _run_signal_sync(
symbol: str,
timeframe: Timeframe,
confirmation_tfs: Optional[List[Timeframe]] = None,
) -> SignalReport:
if confirmation_tfs is None:
confirmation_tfs = TF_CONFIRMATIONS.get(
timeframe, [Timeframe.M15, Timeframe.H4]
)
config = PlatformConfig()
config.primary_timeframe = timeframe
config.confirmation_timeframes = confirmation_tfs
aggregator = SignalAggregator(config)
aggregator.data_provider = _get_shared_dp()
return aggregator.analyze_pair(symbol=symbol)
async def run_signal(
symbol: str,
timeframe: Timeframe,
confirmation_tfs: Optional[List[Timeframe]] = None,
) -> SignalReport:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
_executor, _run_signal_sync, symbol, timeframe, confirmation_tfs
)
# ── Candle-boundary scheduler ────────────────────────────────────────
_autoscan_chats: set[int] = set()
_autoscan_tasks: list[asyncio.Task] = []
def _seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float:
"""Seconds until the next candle close + buffer."""
now = datetime.now(timezone.utc)
total_minutes = now.hour * 60 + now.minute
elapsed_minutes = total_minutes % interval_minutes
elapsed_seconds = elapsed_minutes * 60 + now.second + now.microsecond / 1e6
interval_seconds = interval_minutes * 60
remaining = interval_seconds - elapsed_seconds + buffer
if remaining <= buffer:
remaining += interval_seconds
return remaining
async def _scan_timeframe(
bot,
tf: Timeframe,
confirmation_tfs: List[Timeframe],
) -> None:
"""Scan all pairs for one timeframe and broadcast results."""
reports: List[SignalReport] = []
for symbol in SCAN_PAIRS:
try:
report = await run_signal(symbol, tf, confirmation_tfs)
reports.append(report)
except Exception:
logger.exception(f"Auto-scan failed for {symbol} {tf.value}")
if not reports:
return
_update_level_cache(reports)
if tf == Timeframe.M5:
logger.info("M5 scan: data analysis only — no Telegram broadcast")
return
actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B", "C")]
if not actionable:
logger.info(f"{tf.value} scan: no actionable signals — skipping Telegram message")
return
summary = _format_scan_summary(reports, tf)
for chat_id in list(_autoscan_chats):
try:
await bot.send_message(chat_id, summary, parse_mode=ParseMode.MARKDOWN)
for report in actionable:
detail = format_signal_message(report)
await bot.send_message(chat_id, detail, parse_mode=ParseMode.MARKDOWN)
except Exception:
logger.exception(f"Failed to send to chat {chat_id}")
# ── WebSocket level-breach monitor ──────────────────────────────────
_level_cache: Dict[str, Dict[str, float]] = {}
_last_prices: Dict[str, float] = {}
_scan_cooldowns: Dict[str, float] = {}
_cfg = PlatformConfig()
_WS_API_KEY = os.environ.get("TWELVE_DATA_API_KEY") or _cfg.twelve_data_api_key
_WS_URL = f"wss://ws.twelvedata.com/v1/quotes/price?apikey={_WS_API_KEY}"
_WS_COOLDOWN = _cfg.ws_cooldown_seconds
def _update_level_cache(reports: List[SignalReport]) -> None:
for r in reports:
if r.primary_analysis and r.primary_analysis.key_levels:
levels = {
k: v for k, v in r.primary_analysis.key_levels.items()
if k in _LEVEL_KEYS
}
if levels:
_level_cache[r.symbol] = levels
def _check_level_breach(symbol: str, price: float) -> Optional[str]:
levels = _level_cache.get(symbol)
if not levels:
return None
last = _last_prices.get(symbol)
if last is None:
return None
for name, level in levels.items():
if (last < level <= price) or (last > level >= price):
return name
return None
async def _handle_breach(bot, ws_symbol: str, pair_symbol: str, price: float, level_name: str) -> None:
now = time.time()
if now - _scan_cooldowns.get(pair_symbol, 0) < _WS_COOLDOWN:
return
_scan_cooldowns[pair_symbol] = now
display = PAIR_INFO.get(pair_symbol, {}).get("display", ws_symbol)
label = level_name.replace("_", " ").title()
logger.info(f"Level breach: {display} crossed {label} at {price}")
try:
report = await run_signal(pair_symbol, Timeframe.M15)
_update_level_cache([report])
text = (
f"🔔 *Level Break — {display}*\n"
f"Price crossed *{label}* at `{price}`\n"
f"━━━━━━━━━━━━━━━━━━━━\n"
+ format_signal_message(report)
)
except Exception as exc:
logger.exception(f"Breach scan failed for {display}")
text = f"🔔 *Level Break — {display}*\nPrice crossed {label} at `{price}`\n❌ Scan failed: {exc}"
for chat_id in list(_autoscan_chats):
try:
await bot.send_message(chat_id, text, parse_mode=ParseMode.MARKDOWN)
except Exception:
logger.exception(f"Failed to send breach alert to {chat_id}")
async def _ws_monitor(bot) -> None:
backoff = 1
while _autoscan_chats:
try:
async with websockets.connect(_WS_URL) as ws:
subscribe = {
"action": "subscribe",
"params": {"symbols": ",".join(_WS_SYMBOLS)},
}
await ws.send(json.dumps(subscribe))
logger.info(f"WebSocket connected, subscribed to {len(_WS_SYMBOLS)} symbols")
backoff = 1
async for raw in ws:
if not _autoscan_chats:
break
msg = json.loads(raw)
if msg.get("event") != "price":
continue
ws_symbol = msg.get("symbol")
pair_symbol = _WS_TO_PAIR.get(ws_symbol)
if not pair_symbol:
continue
price = float(msg["price"])
breached = _check_level_breach(pair_symbol, price)
_last_prices[pair_symbol] = price
if breached:
asyncio.create_task(
_handle_breach(bot, ws_symbol, pair_symbol, price, breached)
)
except asyncio.CancelledError:
break
except Exception:
logger.exception(f"WebSocket error, reconnecting in {backoff}s")
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 60)
logger.info("WebSocket monitor stopped")
async def _candle_loop(bot, tf: Timeframe, interval_minutes: int) -> None:
label = tf.value
try:
while _autoscan_chats:
wait = _seconds_until_boundary(interval_minutes, buffer=10)
logger.info(f"{label} auto-scan: next in {wait:.0f}s")
await asyncio.sleep(wait)
if not _autoscan_chats:
break
logger.info(f"{label} auto-scan: firing")
await _scan_timeframe(
bot, tf, AUTOSCAN_CONFIRMATIONS[tf]
)
except asyncio.CancelledError:
pass
finally:
logger.info(f"{label} auto-scan loop stopped")
_AUTOSCAN_LOOPS = [
(Timeframe.M5, 5),
(Timeframe.M15, 15),
(Timeframe.H1, 60),
(Timeframe.H4, 240),
]
_ws_task: Optional[asyncio.Task] = None
def _start_scheduler(bot) -> None:
global _ws_task
alive = [t for t in _autoscan_tasks if not t.done()]
if not alive:
_autoscan_tasks.clear()
for tf, interval in _AUTOSCAN_LOOPS:
_autoscan_tasks.append(
asyncio.create_task(_candle_loop(bot, tf, interval))
)
logger.info("Candle-boundary scheduler started (M5/M15/H1/H4)")
if _ws_task is None or _ws_task.done():
_ws_task = asyncio.create_task(_ws_monitor(bot))
logger.info("WebSocket level-breach monitor started")
def _stop_scheduler() -> None:
global _ws_task
for t in _autoscan_tasks:
t.cancel()
_autoscan_tasks.clear()
if _ws_task and not _ws_task.done():
_ws_task.cancel()
_ws_task = None
logger.info("Candle-boundary scheduler and WebSocket monitor stopped")
# ── Handlers ──────────────────────────────────────────────────────────
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
scanning = "running" if _autoscan_chats else "off"
text = (
"👋 *ForexBot2 — Signal Intelligence*\n\n"
"Pick a pair and timeframe to scan.\n"
"Signals include a full trade setup with entry, SL and TP.\n\n"
f"Auto-scan: *{scanning}* — /autoscan to toggle\n"
"/status — Bot & WebSocket status\n"
"/help — All commands"
)
await update.message.reply_text(
text,
parse_mode=ParseMode.MARKDOWN,
reply_markup=scan_keyboard(),
)
async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
text = (
"📖 *ForexBot2 Commands*\n\n"
"/start — Show scan buttons (4 pairs × 4 TFs)\n"
"/autoscan — Toggle auto-scan on/off\n"
"/status — Bot status, WS connection, live prices\n"
"/help — This message\n\n"
"*Auto-scan* fires at candle close: M5/M15/H1/H4.\n"
"*WebSocket* monitors live prices and sends Level Break Alerts "
"when price crosses swing S/R, Bollinger bands, or EMA200."
)
await update.message.reply_text(text, parse_mode=ParseMode.MARKDOWN)
async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
lines = ["📊 *ForexBot2 Status*\n"]
if _autoscan_chats:
lines.append(f"✅ Auto-scan: *running* ({len(_autoscan_chats)} chat)")
tasks_alive = sum(1 for t in _autoscan_tasks if not t.done())
lines.append(f" Candle loops: {tasks_alive}/{len(_AUTOSCAN_LOOPS)}")
else:
lines.append("⏹ Auto-scan: *stopped*")
if _ws_task and not _ws_task.done():
lines.append("📡 WebSocket: *connected*")
else:
lines.append("❌ WebSocket: *disconnected*")
if _last_prices:
lines.append("\n*Live Prices:*")
for pair in SCAN_PAIRS:
price = _last_prices.get(pair)
display = PAIR_INFO.get(pair, {}).get("display", pair)
if price is None:
lines.append(f" {display}: waiting...")
elif price < 10:
lines.append(f" {display}: `{price:.5f}`")
else:
lines.append(f" {display}: `{price:.3f}`")
else:
lines.append("\n_No live prices yet (WS not streaming)_")
cached_pairs = len(_level_cache)
cached_levels = sum(len(v) for v in _level_cache.values())
if cached_levels:
lines.append(f"\n*Level Cache:* {cached_levels} levels across {cached_pairs} pairs")
else:
lines.append("\n*Level Cache:* empty — breach alerts start after first candle scan")
active_cooldowns = sum(
1 for v in _scan_cooldowns.values()
if time.time() - v < _WS_COOLDOWN
)
if active_cooldowns:
lines.append(f"*Cooldowns:* {active_cooldowns} pair(s) in cooldown")
await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.MARKDOWN)
async def cmd_autoscan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
chat_id = update.effective_chat.id
if chat_id in _autoscan_chats:
_autoscan_chats.discard(chat_id)
if not _autoscan_chats:
_stop_scheduler()
await update.message.reply_text(
"⏹ Auto-scan stopped.",
parse_mode=ParseMode.MARKDOWN,
)
return
_autoscan_chats.add(chat_id)
_start_scheduler(context.bot)
next_m5 = _seconds_until_boundary(5, buffer=10)
next_m15 = _seconds_until_boundary(15, buffer=10)
next_h1 = _seconds_until_boundary(60, buffer=10)
next_h4 = _seconds_until_boundary(240, buffer=10)
await update.message.reply_text(
"✅ *Auto-scan enabled*\n\n"
f"Scanning {len(SCAN_PAIRS)} pairs on M5/M15/H1/H4 at candle close.\n"
f"📡 Live price monitoring active — level-break alerts enabled.\n"
f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | "
f"H1 {next_h1 / 60:.0f}m | H4 {next_h4 / 60:.0f}m\n\n"
"Use /autoscan again to stop.",
parse_mode=ParseMode.MARKDOWN,
)
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(),
)
# ── Boot-time auto-start ──────────────────────────────────────────────
async def _post_init(app: Application) -> None:
"""Register command menu and start scheduler on boot."""
await app.bot.set_my_commands([
("start", "Show scan buttons"),
("autoscan", "Toggle auto-scan on/off"),
("status", "Bot status, WS connection, live prices"),
("help", "All commands"),
])
if not CHAT_ID:
logger.info("TELEGRAM_CHAT_ID not set — auto-scan disabled on boot. "
"Use /autoscan in chat to enable.")
return
chat_id = int(CHAT_ID)
_autoscan_chats.add(chat_id)
logger.info("Running initial scan to populate level cache for WebSocket monitor...")
try:
init_reports = []
for symbol in SCAN_PAIRS:
report = await run_signal(symbol, Timeframe.M15)
init_reports.append(report)
_update_level_cache(init_reports)
logger.info(f"Level cache populated: {sum(len(v) for v in _level_cache.values())} levels")
except Exception:
logger.exception("Initial scan failed — WS will wait for first candle scan")
_start_scheduler(app.bot)
next_m5 = _seconds_until_boundary(5, buffer=10)
next_m15 = _seconds_until_boundary(15, buffer=10)
next_h1 = _seconds_until_boundary(60, buffer=10)
next_h4 = _seconds_until_boundary(240, buffer=10)
logger.info(
f"Auto-scan started for chat {chat_id} — "
f"M5 {next_m5:.0f}s, M15 {next_m15:.0f}s, H1 {next_h1:.0f}s, H4 {next_h4:.0f}s"
)
try:
await app.bot.send_message(
chat_id,
f"🤖 *ForexBot2 started*\n\n"
f"Auto-scanning {len(SCAN_PAIRS)} pairs on M5/M15/H1/H4.\n"
f"📡 Live price monitoring active — level-break alerts enabled.\n"
f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | "
f"H1 {next_h1 / 60:.0f}m | H4 {next_h4 / 60:.0f}m\n\n"
f"/autoscan to stop | Manual buttons via /start",
parse_mode=ParseMode.MARKDOWN,
)
except Exception:
logger.exception("Failed to send boot message")
# ── 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)
.post_init(_post_init)
.build()
)
app.add_handler(CommandHandler("start", cmd_start))
app.add_handler(CommandHandler("help", cmd_help))
app.add_handler(CommandHandler("status", cmd_status))
app.add_handler(CommandHandler("autoscan", cmd_autoscan))
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()