3146a632d4
- Register EUR/USD in config (pip 0.0001, EurUsdStrategyConfig) - Create pair_signals/ module: registry + dispatcher with per-pair config factories (xauusd.py, eurusd.py) for easy add/remove - EUR/USD gets forex-tuned regime weights, London+NY session hours, XAU strategy gates disabled - Add defense-in-depth display_name guards in signal_aggregator - Rename telegram_bot.py → testing_telegram_bot.py with separate bot token, both pairs with dedicated button rows - Add EUR/USD to news engine affected pairs map - 9 new tests (146 total, all passing) - Zero changes to engine.py, data_provider.py, main.py, backtest/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1080 lines
39 KiB
Python
Executable File
1080 lines
39 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
ForexBot2 — Telegram Bot
|
|
========================
|
|
Async Telegram interface with candle-boundary auto-scanner and
|
|
real-time WebSocket level-breach monitor.
|
|
|
|
Manual scan: XAU/USD + EUR/USD 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 XAU/USD.
|
|
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 prevents spam.
|
|
|
|
Usage:
|
|
python testing_telegram_bot.py
|
|
TELEGRAM_TOKEN=<token> TELEGRAM_CHAT_ID=<id> python testing_telegram_bot.py
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import signal
|
|
import sys
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from datetime import datetime, timezone
|
|
from zoneinfo import ZoneInfo
|
|
from typing import Dict, List, Optional, Set
|
|
|
|
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,
|
|
XAUUSD, EURUSD, PAIR_INFO,
|
|
)
|
|
from data_provider import ForexDataProvider
|
|
from signal_aggregator import SignalAggregator, SignalReport
|
|
from pair_signals import produce_signal
|
|
|
|
# ── 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", "8882954582:AAEOi2MzoMbJjozDMiRtFBHc2muix_MtaQQ")
|
|
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6025899022")
|
|
|
|
# ── Pairs & buttons ───────────────────────────────────────────────────
|
|
|
|
SCAN_PAIRS = [XAUUSD, EURUSD]
|
|
|
|
_WS_TO_PAIR = {
|
|
"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",
|
|
"ema_trend", "bb_upper", "bb_lower",
|
|
"kc_upper", "kc_lower",
|
|
)
|
|
|
|
# ── Live signal stats cache ──────────────────────────────────────────
|
|
# symbol → { tf_label → SignalReport }
|
|
_signal_stats: Dict[str, Dict[str, SignalReport]] = {}
|
|
_stats_updated: Optional[float] = None
|
|
|
|
SCAN_BUTTONS = [
|
|
{"cb": "scan_xauusd_m5", "symbol": XAUUSD, "tf": Timeframe.M5, "label": "XAU 5m"},
|
|
{"cb": "scan_xauusd_m15", "symbol": XAUUSD, "tf": Timeframe.M15, "label": "XAU 15m"},
|
|
{"cb": "scan_xauusd_h1", "symbol": XAUUSD, "tf": Timeframe.H1, "label": "XAU 1h"},
|
|
{"cb": "scan_xauusd_h4", "symbol": XAUUSD, "tf": Timeframe.H4, "label": "XAU 4h"},
|
|
{"cb": "scan_xauusd_all", "symbol": XAUUSD, "tf": None, "label": "XAU All"},
|
|
{"cb": "scan_eurusd_m5", "symbol": EURUSD, "tf": Timeframe.M5, "label": "EUR 5m"},
|
|
{"cb": "scan_eurusd_m15", "symbol": EURUSD, "tf": Timeframe.M15, "label": "EUR 15m"},
|
|
{"cb": "scan_eurusd_h1", "symbol": EURUSD, "tf": Timeframe.H1, "label": "EUR 1h"},
|
|
{"cb": "scan_eurusd_h4", "symbol": EURUSD, "tf": Timeframe.H4, "label": "EUR 4h"},
|
|
{"cb": "scan_eurusd_all", "symbol": EURUSD, "tf": None, "label": "EUR All"},
|
|
]
|
|
|
|
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=4)
|
|
|
|
# ── Markdown escaping ─────────────────────────────────────────────────
|
|
|
|
def _escape_md(text: str) -> str:
|
|
for ch in ("*", "_", "`", "[", "]"):
|
|
text = text.replace(ch, "\\" + ch)
|
|
return text
|
|
|
|
|
|
def _fmt_price(price: float) -> str:
|
|
return f"{price:.2f}" if price >= 10 else f"{price:.5f}"
|
|
|
|
|
|
# ── 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": "❌",
|
|
}
|
|
|
|
_SIGNAL_TAG = {
|
|
SignalStrength.STRONG_BUY: "Strong Buy",
|
|
SignalStrength.BUY: "Bullish",
|
|
SignalStrength.NEUTRAL: "Neutral",
|
|
SignalStrength.SELL: "Bearish",
|
|
SignalStrength.STRONG_SELL: "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:
|
|
tag = _SIGNAL_TAG.get(ind.signal, "Neutral")
|
|
short = ind.description.split(" | ")[0] if " | " in ind.description else ind.description
|
|
return f"[{tag}] *{_escape_md(ind.name)}*: {_escape_md(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")
|
|
regime_str = _escape_md(report.market_regime.replace("_", " ").title())
|
|
vol_str = _escape_md(str(report.volatility_regime))
|
|
lines += [
|
|
f"🔍 *{report.display_name} Signal Analysis* ({report.primary_analysis.timeframe if report.primary_analysis else ''})",
|
|
f"🕐 {now}",
|
|
"",
|
|
f"💰 Price: *{_fmt_price(report.current_price)}*",
|
|
f"📊 Regime: {regime_str} | Vol: {vol_str}",
|
|
"",
|
|
"━━━━━━━━━━━━━━━━━━━━",
|
|
f"{emoji} *{label}* {qe}{report.signal_quality} | Score: {report.final_score:+.1f} | {report.final_confidence:.0f}% conf",
|
|
f"_{_escape_md(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"⚠️ {_escape_md(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: `{_fmt_price(ts.entry_price)}`",
|
|
f"🛑 SL: `{_fmt_price(ts.stop_loss)}` ({ts.risk_pips:.1f} pips)",
|
|
f"🎯 TP1: `{_fmt_price(ts.take_profit_1)}` ({ts.reward_pips:.1f} pips)",
|
|
]
|
|
if ts.take_profit_2:
|
|
lines.append(f"🎯 TP2: `{_fmt_price(ts.take_profit_2)}`")
|
|
lines.append(f"⚖️ RR: 1:{ts.risk_reward:.1f}")
|
|
|
|
lines += ["", "━━━━━━━━━━━━━━━━━━━━",
|
|
"_Educational / research use only._"]
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ── 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.")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _update_signal_stats(reports: List[SignalReport], tf: Timeframe) -> None:
|
|
global _stats_updated
|
|
for r in reports:
|
|
if r.symbol not in _signal_stats:
|
|
_signal_stats[r.symbol] = {}
|
|
_signal_stats[r.symbol][tf.value] = r
|
|
_stats_updated = time.time()
|
|
|
|
|
|
_SHORT_SIGNAL = {
|
|
SignalStrength.STRONG_BUY: "Buy",
|
|
SignalStrength.BUY: "Buy",
|
|
SignalStrength.NEUTRAL: "Neutral",
|
|
SignalStrength.SELL: "Sell",
|
|
SignalStrength.STRONG_SELL: "Sell",
|
|
}
|
|
|
|
|
|
_TF_DISPLAY = {"5min": "M5", "15min": "M15", "1h": "H1", "4h": "H4", "1d": "D1"}
|
|
_DASH_TFS = [Timeframe.M15, Timeframe.H1, Timeframe.H4]
|
|
|
|
|
|
def _format_stats_dashboard() -> str:
|
|
if not _signal_stats:
|
|
return "📊 *Live Dashboard*\n\n_Waiting for first scan…_"
|
|
|
|
_ny_tz = ZoneInfo("America/New_York")
|
|
now = datetime.now(_ny_tz).strftime("%H:%M:%S %Z")
|
|
lines = [f"📊 *Live Dashboard* — {now}", ""]
|
|
|
|
for symbol in SCAN_PAIRS:
|
|
display = PAIR_INFO.get(symbol, {}).get("display", symbol)
|
|
pair_data = _signal_stats.get(symbol, {})
|
|
|
|
ws_price = _last_prices.get(symbol)
|
|
latest = None
|
|
for tf in reversed(_DASH_TFS):
|
|
if tf.value in pair_data:
|
|
latest = pair_data[tf.value]
|
|
break
|
|
|
|
price = ws_price or (latest.current_price if latest else 0)
|
|
price_str = f"`{_fmt_price(price)}`"
|
|
|
|
regime = latest.market_regime.replace("_", " ").title() if latest else ""
|
|
|
|
tf_parts = []
|
|
for tf in _DASH_TFS:
|
|
label = _TF_DISPLAY.get(tf.value, tf.value)
|
|
r = pair_data.get(tf.value)
|
|
if not r:
|
|
tf_parts.append(f"{label}: ⬜—")
|
|
continue
|
|
emoji = _SIGNAL_EMOJI.get(r.final_signal, "⬜")
|
|
sig = _SHORT_SIGNAL.get(r.final_signal, "Neutral")
|
|
tf_parts.append(f"{label}: {emoji}{sig}")
|
|
|
|
lines.append(f"*{display}* {price_str} _{_escape_md(regime)}_")
|
|
lines.append(" | ".join(tf_parts))
|
|
|
|
if latest and latest.trade_setup and latest.final_signal not in (SignalStrength.NEUTRAL,):
|
|
ts = latest.trade_setup
|
|
d = "📈" if ts.direction == "BUY" else "📉"
|
|
lines.append(
|
|
f"{d} {ts.direction} `{_fmt_price(ts.entry_price)}` "
|
|
f"SL `{_fmt_price(ts.stop_loss)}` TP `{_fmt_price(ts.take_profit_1)}` "
|
|
f"RR 1:{ts.risk_reward:.1f}"
|
|
)
|
|
|
|
lines.append("")
|
|
|
|
lines.append("_🔄 live refresh_")
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ── Live dashboard (edit-in-place) ───────────────────────────────────
|
|
|
|
_dashboard_msg_ids: Dict[int, int] = {}
|
|
_dashboard_task: Optional[asyncio.Task] = None
|
|
_last_dashboard_refresh: float = 0
|
|
_DASHBOARD_REFRESH_INTERVAL = 30
|
|
_dashboard_lock = asyncio.Lock()
|
|
|
|
|
|
async def _send_dashboard(bot, chat_id: int) -> None:
|
|
text = _format_stats_dashboard()
|
|
try:
|
|
msg = await bot.send_message(chat_id, text, parse_mode=ParseMode.MARKDOWN)
|
|
_dashboard_msg_ids[chat_id] = msg.message_id
|
|
except Exception as exc:
|
|
logger.warning(f"Dashboard send failed for chat {chat_id}: {exc}")
|
|
|
|
|
|
async def _refresh_dashboard(bot, force: bool = False) -> None:
|
|
global _last_dashboard_refresh
|
|
async with _dashboard_lock:
|
|
now = time.time()
|
|
if not force and (now - _last_dashboard_refresh) < _DASHBOARD_REFRESH_INTERVAL:
|
|
return
|
|
_last_dashboard_refresh = now
|
|
|
|
text = _format_stats_dashboard()
|
|
for chat_id in list(_autoscan_chats):
|
|
msg_id = _dashboard_msg_ids.get(chat_id)
|
|
if not msg_id:
|
|
await _send_dashboard(bot, chat_id)
|
|
continue
|
|
try:
|
|
await bot.edit_message_text(
|
|
text, chat_id=chat_id, message_id=msg_id,
|
|
parse_mode=ParseMode.MARKDOWN,
|
|
)
|
|
except Exception as exc:
|
|
if "message is not modified" not in str(exc).lower():
|
|
logger.warning(f"Dashboard edit failed for chat {chat_id}: {exc}")
|
|
|
|
|
|
async def _dashboard_loop(bot) -> None:
|
|
try:
|
|
while _autoscan_chats:
|
|
await asyncio.sleep(_DASHBOARD_REFRESH_INTERVAL)
|
|
if not _autoscan_chats:
|
|
break
|
|
await _refresh_dashboard(bot)
|
|
except asyncio.CancelledError:
|
|
pass
|
|
finally:
|
|
logger.info("Dashboard refresh loop stopped")
|
|
|
|
|
|
# ── Keyboard helper ───────────────────────────────────────────────────
|
|
|
|
def scan_keyboard() -> InlineKeyboardMarkup:
|
|
rows = []
|
|
for pair in SCAN_PAIRS:
|
|
pair_btns = [b for b in SCAN_BUTTONS if b["symbol"] == pair]
|
|
row = [
|
|
InlineKeyboardButton(b["label"], callback_data=b["cb"])
|
|
for b in pair_btns
|
|
]
|
|
rows.append(row)
|
|
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]
|
|
)
|
|
return produce_signal(
|
|
symbol=symbol,
|
|
primary_tf=timeframe,
|
|
data_provider=_get_shared_dp(),
|
|
confirmation_tfs=confirmation_tfs,
|
|
)
|
|
|
|
|
|
async def run_signal(
|
|
symbol: str,
|
|
timeframe: Timeframe,
|
|
confirmation_tfs: Optional[List[Timeframe]] = None,
|
|
) -> SignalReport:
|
|
loop = asyncio.get_running_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."""
|
|
tasks = [run_signal(symbol, tf, confirmation_tfs) for symbol in SCAN_PAIRS]
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
reports: List[SignalReport] = []
|
|
for i, result in enumerate(results):
|
|
if isinstance(result, Exception):
|
|
logger.exception(f"Auto-scan failed for {SCAN_PAIRS[i]} {tf.value}: {result}")
|
|
else:
|
|
reports.append(result)
|
|
|
|
if not reports:
|
|
return
|
|
|
|
_update_level_cache(reports)
|
|
_update_signal_stats(reports, tf)
|
|
|
|
await _refresh_dashboard(bot)
|
|
|
|
actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B", "C")]
|
|
for chat_id in list(_autoscan_chats):
|
|
for report in actionable:
|
|
detail = format_signal_message(report)
|
|
try:
|
|
await bot.send_message(chat_id, detail, parse_mode=ParseMode.MARKDOWN)
|
|
except Exception as exc:
|
|
logger.warning(f"Failed to send signal for chat {chat_id}: {exc}")
|
|
|
|
|
|
# ── WebSocket level-breach monitor ──────────────────────────────────
|
|
|
|
_level_cache: Dict[str, Dict[str, float]] = {}
|
|
_level_cache_ts: Dict[str, float] = {}
|
|
_LEVEL_CACHE_TTL = 3600
|
|
_last_prices: Dict[str, float] = {}
|
|
_last_price_ts: 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
|
|
|
|
_breach_tasks: Set[asyncio.Task] = set()
|
|
|
|
|
|
def _update_level_cache(reports: List[SignalReport]) -> None:
|
|
now = time.time()
|
|
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
|
|
_level_cache_ts[r.symbol] = now
|
|
|
|
|
|
def _check_level_breach(symbol: str, price: float) -> Optional[str]:
|
|
levels = _level_cache.get(symbol)
|
|
if not levels:
|
|
return None
|
|
|
|
cache_age = time.time() - _level_cache_ts.get(symbol, 0)
|
|
if cache_age > _LEVEL_CACHE_TTL:
|
|
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
|
|
|
|
|
|
def _cleanup_cooldowns() -> None:
|
|
now = time.time()
|
|
expired = [k for k, v in _scan_cooldowns.items() if now - v > _WS_COOLDOWN * 2]
|
|
for k in expired:
|
|
del _scan_cooldowns[k]
|
|
|
|
|
|
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} (alert disabled, cache updated)")
|
|
|
|
try:
|
|
report = await run_signal(pair_symbol, Timeframe.M15)
|
|
_update_level_cache([report])
|
|
_update_signal_stats([report], Timeframe.M15)
|
|
except Exception:
|
|
logger.exception(f"Breach scan failed for {display}")
|
|
|
|
|
|
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
|
|
|
|
try:
|
|
msg = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
logger.warning(f"WebSocket: malformed JSON, skipping")
|
|
continue
|
|
|
|
if msg.get("event") != "price":
|
|
continue
|
|
|
|
ws_symbol = msg.get("symbol")
|
|
pair_symbol = _WS_TO_PAIR.get(ws_symbol)
|
|
if not pair_symbol:
|
|
continue
|
|
|
|
try:
|
|
price = float(msg["price"])
|
|
except (KeyError, ValueError, TypeError) as e:
|
|
logger.warning(f"WebSocket: bad price data for {ws_symbol}: {e}")
|
|
continue
|
|
|
|
breached = _check_level_breach(pair_symbol, price)
|
|
_last_prices[pair_symbol] = price
|
|
_last_price_ts[pair_symbol] = time.time()
|
|
|
|
if breached:
|
|
task = asyncio.create_task(
|
|
_handle_breach(bot, ws_symbol, pair_symbol, price, breached)
|
|
)
|
|
_breach_tasks.add(task)
|
|
task.add_done_callback(_breach_tasks.discard)
|
|
|
|
_cleanup_cooldowns()
|
|
|
|
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, _dashboard_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")
|
|
|
|
if _dashboard_task is None or _dashboard_task.done():
|
|
_dashboard_task = asyncio.create_task(_dashboard_loop(bot))
|
|
logger.info(f"Dashboard {_DASHBOARD_REFRESH_INTERVAL}s refresh loop started")
|
|
|
|
|
|
def _stop_scheduler() -> None:
|
|
global _ws_task, _dashboard_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
|
|
if _dashboard_task and not _dashboard_task.done():
|
|
_dashboard_task.cancel()
|
|
_dashboard_task = None
|
|
_dashboard_msg_ids.clear()
|
|
for t in list(_breach_tasks):
|
|
t.cancel()
|
|
_breach_tasks.clear()
|
|
logger.info("Scheduler, WebSocket, and dashboard loop 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"
|
|
"/scan — Quick scan buttons\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 — Welcome & scan buttons\n"
|
|
"/scan — Quick scan buttons (XAU/USD + EUR/USD)\n"
|
|
"/autoscan — Toggle auto-scan on/off\n"
|
|
"/status — Bot status, WS connection, live prices\n"
|
|
"/cache — Show data cache age per pair/TF\n"
|
|
"/clearcache — Wipe data cache, force fresh fetch\n"
|
|
"/help — This message\n\n"
|
|
"*Auto-scan* fires at candle close: M5/M15/H1/H4.\n"
|
|
f"Live dashboard auto-refreshes every {_DASHBOARD_REFRESH_INTERVAL}s.\n"
|
|
"*WebSocket* monitors live prices and triggers breach scans."
|
|
)
|
|
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 (WebSocket):*")
|
|
for ws_sym, pair in _WS_TO_PAIR.items():
|
|
price = _last_prices.get(pair)
|
|
display = PAIR_INFO.get(pair, {}).get("display", ws_sym)
|
|
age = time.time() - _last_price_ts.get(pair, 0)
|
|
if price is None:
|
|
lines.append(f" {display}: waiting...")
|
|
else:
|
|
lines.append(f" {display}: `{_fmt_price(price)}` ({age:.0f}s ago)")
|
|
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:
|
|
oldest_age = time.time() - min(_level_cache_ts.values()) if _level_cache_ts else 0
|
|
lines.append(f"\n*Level Cache:* {cached_levels} levels across {cached_pairs} pairs (oldest {oldest_age:.0f}s)")
|
|
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")
|
|
|
|
active_breach = sum(1 for t in _breach_tasks if not t.done())
|
|
if active_breach:
|
|
lines.append(f"*Breach tasks:* {active_breach} running")
|
|
|
|
await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.MARKDOWN)
|
|
|
|
|
|
async def cmd_cache(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
dp = _get_shared_dp()
|
|
if not dp._cache_timestamps:
|
|
await update.message.reply_text("📭 Cache is empty — no data fetched yet.")
|
|
return
|
|
|
|
now = time.time()
|
|
lines = ["📦 *Data Cache Status*\n"]
|
|
|
|
for pair in SCAN_PAIRS:
|
|
display = PAIR_INFO.get(pair, {}).get("display", pair)
|
|
pair_lines = []
|
|
for tf in [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4, Timeframe.D1]:
|
|
cache_key = f"{pair}_{tf.value}"
|
|
ts = dp._cache_timestamps.get(cache_key)
|
|
if ts is None:
|
|
continue
|
|
age_min = (now - ts) / 60
|
|
df = dp._cache.get(cache_key)
|
|
last_bar = df.index[-1].strftime("%H:%M") if df is not None and not df.empty else "?"
|
|
fresh = "✅" if age_min < dp.CANDLE_SECONDS.get(tf, 3600) / 60 else "⚠️"
|
|
pair_lines.append(f" {tf.value:<5} {fresh} fetched `{age_min:.0f}m` ago | last bar `{last_bar}`")
|
|
if pair_lines:
|
|
lines.append(f"*{display}*")
|
|
lines.extend(pair_lines)
|
|
|
|
await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.MARKDOWN)
|
|
|
|
|
|
async def cmd_clearcache(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
dp = _get_shared_dp()
|
|
count = dp.clear_cache()
|
|
await update.message.reply_text(
|
|
f"🗑 Cache cleared ({count} entries wiped).\nNext scan will fetch fresh data from API.",
|
|
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 XAU/USD + EUR/USD on M5/M15/H1/H4 at candle close.\n"
|
|
f"📊 Live dashboard refreshes every {_DASHBOARD_REFRESH_INTERVAL}s.\n"
|
|
f"📡 Live price monitoring active.\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 cmd_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
await update.message.reply_text(
|
|
"🔍 *Pick a pair & timeframe*",
|
|
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
|
|
|
|
if btn["tf"] is None:
|
|
await _cb_scan_all(query, symbol=btn["symbol"])
|
|
return
|
|
|
|
display = PAIR_INFO.get(btn["symbol"], {}).get("display", btn["symbol"])
|
|
await query.edit_message_text(
|
|
f"⏳ Scanning {display} {btn['label']}…",
|
|
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(),
|
|
)
|
|
|
|
|
|
async def _cb_scan_all(query, symbol: str = XAUUSD) -> None:
|
|
display = PAIR_INFO.get(symbol, {}).get("display", symbol)
|
|
await query.edit_message_text(
|
|
f"⏳ Scanning {display} across M5 / M15 / H1 / H4…",
|
|
parse_mode=ParseMode.MARKDOWN,
|
|
)
|
|
|
|
tfs = [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4]
|
|
tasks = [run_signal(symbol, tf) for tf in tfs]
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
reports = []
|
|
errors = []
|
|
for tf, result in zip(tfs, results):
|
|
if isinstance(result, Exception):
|
|
errors.append(f"❌ {tf.value}: {result}")
|
|
else:
|
|
reports.append(result)
|
|
_update_signal_stats([result], tf)
|
|
|
|
if reports:
|
|
_update_level_cache(reports)
|
|
|
|
lines = [
|
|
f"📊 *{display} — All Timeframes*",
|
|
f"🕐 {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
|
|
"",
|
|
]
|
|
|
|
for r in reports:
|
|
emoji = _SIGNAL_EMOJI.get(r.final_signal, "⬜")
|
|
qe = _QUALITY_EMOJI.get(r.signal_quality, "")
|
|
tf_label = r.primary_analysis.timeframe if r.primary_analysis else "?"
|
|
sig = _signal_label(r.final_signal)
|
|
regime = r.market_regime.replace("_", " ").title()
|
|
lines.append(
|
|
f"{emoji} *{tf_label}* {sig} {qe}{r.signal_quality} "
|
|
f"Score: {r.final_score:+.1f} {r.final_confidence:.0f}%"
|
|
)
|
|
lines.append(f" _{_escape_md(regime)}_ | {'✅ MTF' if r.mtf_agreement else '❌ MTF'}")
|
|
if r.trade_setup and r.final_signal != SignalStrength.NEUTRAL:
|
|
ts = r.trade_setup
|
|
d = "📈" if ts.direction == "BUY" else "📉"
|
|
lines.append(
|
|
f" {d} {ts.direction} `{_fmt_price(ts.entry_price)}` "
|
|
f"SL `{_fmt_price(ts.stop_loss)}` TP `{_fmt_price(ts.take_profit_1)}` "
|
|
f"RR 1:{ts.risk_reward:.1f}"
|
|
)
|
|
lines.append("")
|
|
|
|
for err in errors:
|
|
lines.append(err)
|
|
|
|
lines.append("_Use buttons below for detailed single-TF view._")
|
|
|
|
await query.edit_message_text(
|
|
"\n".join(lines),
|
|
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", "Welcome & scan buttons"),
|
|
("scan", "Quick scan buttons (XAU/USD + EUR/USD)"),
|
|
("autoscan", "Toggle auto-scan on/off"),
|
|
("status", "Bot status, WS connection, live prices"),
|
|
("cache", "Show data cache age per pair/TF"),
|
|
("clearcache", "Wipe data cache, force fresh fetch"),
|
|
("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 and signal stats...")
|
|
try:
|
|
tasks = [
|
|
asyncio.wait_for(run_signal(sym, Timeframe.M15), timeout=30)
|
|
for sym in SCAN_PAIRS
|
|
]
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
init_reports = [r for r in results if isinstance(r, SignalReport)]
|
|
for i, r in enumerate(results):
|
|
if isinstance(r, Exception):
|
|
logger.error(f"Initial scan failed for {SCAN_PAIRS[i]}: {r}")
|
|
if init_reports:
|
|
_update_level_cache(init_reports)
|
|
_update_signal_stats(init_reports, Timeframe.M15)
|
|
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 XAU/USD + EUR/USD on M5/M15/H1/H4.\n"
|
|
f"📊 Live dashboard refreshes every {_DASHBOARD_REFRESH_INTERVAL}s.\n"
|
|
f"📡 Live price monitoring active.\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",
|
|
parse_mode=ParseMode.MARKDOWN,
|
|
)
|
|
await _send_dashboard(app.bot, chat_id)
|
|
except Exception:
|
|
logger.exception("Failed to send boot message")
|
|
|
|
|
|
# ── Graceful shutdown ─────────────────────────────────────────────────
|
|
|
|
async def _post_shutdown(app: Application) -> None:
|
|
logger.info("Shutting down ForexBot2...")
|
|
_stop_scheduler()
|
|
_executor.shutdown(wait=False)
|
|
logger.info("Shutdown complete")
|
|
|
|
|
|
# ── 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)
|
|
.post_shutdown(_post_shutdown)
|
|
.build()
|
|
)
|
|
|
|
app.add_handler(CommandHandler("start", cmd_start))
|
|
app.add_handler(CommandHandler("scan", cmd_scan))
|
|
app.add_handler(CommandHandler("help", cmd_help))
|
|
app.add_handler(CommandHandler("status", cmd_status))
|
|
app.add_handler(CommandHandler("autoscan", cmd_autoscan))
|
|
app.add_handler(CommandHandler("cache", cmd_cache))
|
|
app.add_handler(CommandHandler("clearcache", cmd_clearcache))
|
|
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()
|