Telegram bot: WS heartbeat, breach alerts, alert dedup, interactive menu

WebSocket:
- Send heartbeat every 10s — Twelve Data drops idle connections, so the
  monitor previously died on weekends/quiet hours and churned reconnects
- Log failed symbol subscriptions from subscribe-status events
- Stamp fresh live tick price + age into every alert message

Alerts:
- Re-enable level-breach alerts: WS breach triggers M15 rescan and sends
  the report when actionable (was scanning and discarding the result)
- Dedup: identical (signal, quality, direction) per pair/TF is sent once,
  re-armed after 1h — no more repeat alerts every candle
- Configurable minimum alert quality (C+/B+/A), cycled from the menu
- Trade setup shows dollar risk/reward alongside pips

Interactive menu (/start, /scan):
- Timeframe scans, Scan All, auto-scan ON/OFF toggle, alert-quality
  cycle, status, and help as inline buttons
- Markdown-safe edits: 'message not modified' swallowed, parse failures
  fall back to plain text; exception text escaped
This commit is contained in:
harwinder
2026-07-08 21:30:16 -04:00
parent 1f076c0b54
commit 09ee5208d4
+208 -36
View File
@@ -38,6 +38,7 @@ import websockets
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.constants import ParseMode
from telegram.error import BadRequest
from telegram.ext import (
Application,
CallbackQueryHandler,
@@ -177,6 +178,18 @@ def _indicator_line(name: str, ind) -> str:
return f"[{tag}] *{_escape_md(ind.name)}*: {_escape_md(short)}"
def _live_price_line(symbol: str) -> Optional[str]:
"""Fresh WebSocket tick for this symbol, if we have one (<2 min old)."""
price = _last_prices.get(symbol)
ts = _last_price_ts.get(symbol, 0)
if price is None:
return None
age = time.time() - ts
if age > 120:
return None
return f"📡 Live: *{_fmt_price(price)}* ({age:.0f}s tick)"
def format_signal_message(report: SignalReport) -> str:
sig = report.final_signal
emoji = _SIGNAL_EMOJI.get(sig, "⬜")
@@ -193,7 +206,12 @@ def format_signal_message(report: SignalReport) -> str:
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"💰 Close: *{_fmt_price(report.current_price)}*",
]
live = _live_price_line(report.symbol)
if live:
lines.append(live)
lines += [
f"📊 Regime: {regime_str} | Vol: {vol_str}",
"",
"━━━━━━━━━━━━━━━━━━━━",
@@ -221,14 +239,16 @@ def format_signal_message(report: SignalReport) -> str:
if report.trade_setup and sig not in (SignalStrength.NEUTRAL,):
ts = report.trade_setup
dir_emoji = "📈" if ts.direction == "BUY" else "📉"
risk_usd = ts.risk_pips * ts.pip_value
reward_usd = ts.reward_pips * ts.pip_value
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)",
f"🛑 SL: `{_fmt_price(ts.stop_loss)}` (${risk_usd:.2f} / {ts.risk_pips:.0f} pips)",
f"🎯 TP1: `{_fmt_price(ts.take_profit_1)}` (${reward_usd:.2f} / {ts.reward_pips:.0f} pips)",
]
if ts.take_profit_2:
lines.append(f"🎯 TP2: `{_fmt_price(ts.take_profit_2)}`")
@@ -283,6 +303,45 @@ def scan_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(rows)
def main_menu_keyboard() -> InlineKeyboardMarkup:
"""Full interactive menu: scans + settings + status in one keyboard."""
tf_buttons = [b for b in SCAN_BUTTONS if b["tf"] is not None]
all_button = next((b for b in SCAN_BUTTONS if b["tf"] is None), None)
autoscan_label = "🔔 Auto-scan: ON" if _autoscan_chats else "🔕 Auto-scan: OFF"
quality_label = f"🎚 Alerts: {_alert_min_quality}+" if _alert_min_quality != "A" else "🎚 Alerts: A only"
rows = [
[InlineKeyboardButton(b["label"], callback_data=b["cb"]) for b in tf_buttons],
]
if all_button:
rows.append([InlineKeyboardButton(all_button["label"], callback_data=all_button["cb"])])
rows.append([
InlineKeyboardButton(autoscan_label, callback_data="menu_autoscan"),
InlineKeyboardButton(quality_label, callback_data="menu_quality"),
])
rows.append([
InlineKeyboardButton("📊 Status", callback_data="menu_status"),
InlineKeyboardButton("ℹ️ Help", callback_data="menu_help"),
])
return InlineKeyboardMarkup(rows)
async def _safe_edit(query, text: str, reply_markup=None) -> None:
"""edit_message_text that survives 'message is not modified' and
Markdown parse failures (falls back to plain text)."""
try:
await query.edit_message_text(
text, parse_mode=ParseMode.MARKDOWN, reply_markup=reply_markup
)
except BadRequest as exc:
if "not modified" in str(exc).lower():
return
logger.warning(f"Markdown edit failed ({exc}); retrying as plain text")
try:
await query.edit_message_text(text, reply_markup=reply_markup)
except BadRequest:
pass
# ── Signal runner (shared data provider for cache reuse) ──────────────
def _run_signal_sync(
@@ -313,6 +372,40 @@ async def run_signal(
)
# ── Alert gating (quality threshold + dedup) ─────────────────────────
_QUALITY_RANK = {"A+": 4, "A": 3, "B": 2, "C": 1}
_ALERT_QUALITY_CYCLE = ["C", "B", "A"] # C+ → B+ → A-only
_alert_min_quality = "C"
# Fingerprint of the last alert per (symbol, timeframe) so a persisting
# signal doesn't re-send an identical message every candle.
_last_alert_fp: Dict[str, tuple] = {}
_last_alert_ts: Dict[str, float] = {}
_ALERT_REARM_SECONDS = 3600 # identical signal re-alerts after 1h
def _passes_quality(quality: str) -> bool:
return _QUALITY_RANK.get(quality, 0) >= _QUALITY_RANK.get(_alert_min_quality, 1)
def _should_alert(key: str, report: SignalReport) -> bool:
"""True when this signal is new/changed for the key, or re-armed."""
setup = report.trade_setup
fp = (
report.final_signal.name,
report.signal_quality,
setup.direction if setup else None,
)
now = time.time()
if (_last_alert_fp.get(key) == fp
and now - _last_alert_ts.get(key, 0) < _ALERT_REARM_SECONDS):
return False
_last_alert_fp[key] = fp
_last_alert_ts[key] = now
return True
# ── Candle-boundary scheduler ────────────────────────────────────────
_autoscan_chats: Set[int] = set()
@@ -352,7 +445,11 @@ async def _scan_timeframe(
_update_level_cache(reports)
actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B", "C")]
actionable = [
r for r in reports
if _passes_quality(r.signal_quality)
and _should_alert(f"{r.symbol}_{tf.value}", r)
]
for chat_id in list(_autoscan_chats):
for report in actionable:
detail = format_signal_message(report)
@@ -427,18 +524,46 @@ async def _handle_breach(bot, ws_symbol: str, pair_symbol: str, price: float, le
_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)")
logger.info(f"Level breach: {display} crossed {label} at {price} — rescanning M15")
try:
report = await run_signal(pair_symbol, Timeframe.M15)
_update_level_cache([report])
except Exception:
logger.exception(f"Breach scan failed for {display}")
return
# Alert only when the rescan produced an actionable, changed signal —
# same gates as the auto-scanner, so a breach can't spam the channel.
if (report.final_signal == SignalStrength.NEUTRAL
or not _passes_quality(report.signal_quality)
or not _should_alert(f"{pair_symbol}_{Timeframe.M15.value}", report)):
return
header = (
f"⚡ *LEVEL BREAK* — {_escape_md(display)} crossed "
f"*{_escape_md(label)}* @ `{_fmt_price(price)}`\n\n"
)
detail = header + format_signal_message(report)
for chat_id in list(_autoscan_chats):
try:
await bot.send_message(chat_id, detail, parse_mode=ParseMode.MARKDOWN)
except Exception as exc:
logger.warning(f"Failed to send breach alert to chat {chat_id}: {exc}")
async def _ws_heartbeat(ws) -> None:
"""Twelve Data drops idle WS connections; heartbeat every 10s keeps it
alive through quiet periods (weekends, low-liquidity hours)."""
while True:
await asyncio.sleep(10)
await ws.send(json.dumps({"action": "heartbeat"}))
async def _ws_monitor(bot) -> None:
backoff = 1
while _autoscan_chats:
hb_task: Optional[asyncio.Task] = None
try:
async with websockets.connect(_WS_URL) as ws:
subscribe = {
@@ -448,6 +573,7 @@ async def _ws_monitor(bot) -> None:
await ws.send(json.dumps(subscribe))
logger.info(f"WebSocket connected, subscribed to {len(_WS_SYMBOLS)} symbols")
backoff = 1
hb_task = asyncio.create_task(_ws_heartbeat(ws))
async for raw in ws:
if not _autoscan_chats:
@@ -459,7 +585,13 @@ async def _ws_monitor(bot) -> None:
logger.warning(f"WebSocket: malformed JSON, skipping")
continue
if msg.get("event") != "price":
event = msg.get("event")
if event == "subscribe-status":
fails = msg.get("fails") or []
if fails:
logger.warning(f"WebSocket: failed to subscribe: {fails}")
continue
if event != "price":
continue
ws_symbol = msg.get("symbol")
@@ -492,6 +624,9 @@ async def _ws_monitor(bot) -> None:
logger.exception(f"WebSocket error, reconnecting in {backoff}s")
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 60)
finally:
if hb_task is not None:
hb_task.cancel()
logger.info("WebSocket monitor stopped")
@@ -572,27 +707,31 @@ async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(
text,
parse_mode=ParseMode.MARKDOWN,
reply_markup=scan_keyboard(),
reply_markup=main_menu_keyboard(),
)
async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
text = (
_HELP_TEXT = (
"📖 *ForexBot2 Commands*\n\n"
"/start — Welcome & scan buttons\n"
"/scan — Quick XAU/USD scan buttons\n"
"/start — Welcome & interactive menu\n"
"/scan — Quick XAU/USD scan menu\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"
"*WebSocket* monitors live prices and triggers breach scans."
)
await update.message.reply_text(text, parse_mode=ParseMode.MARKDOWN)
"*Alerts* button sets the minimum quality grade that gets sent.\n"
"*WebSocket* streams live prices; key-level breaches trigger an "
"instant M15 rescan and alert (5-min cooldown)."
)
async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(_HELP_TEXT, parse_mode=ParseMode.MARKDOWN)
def _status_text() -> str:
lines = ["📊 *ForexBot2 Status*\n"]
if _autoscan_chats:
@@ -639,7 +778,13 @@ async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None
if active_breach:
lines.append(f"*Breach tasks:* {active_breach} running")
await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.MARKDOWN)
lines.append(f"\n*Alert quality:* {_alert_min_quality}+ "
f"| re-arm {_ALERT_REARM_SECONDS // 60}m")
return "\n".join(lines)
async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(_status_text(), parse_mode=ParseMode.MARKDOWN)
async def cmd_cache(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -715,7 +860,7 @@ async def cmd_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(
"🔍 *XAU/USD — Pick a timeframe*",
parse_mode=ParseMode.MARKDOWN,
reply_markup=scan_keyboard(),
reply_markup=main_menu_keyboard(),
)
@@ -731,30 +876,60 @@ async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await _cb_scan_all(query)
return
await query.edit_message_text(
f"⏳ Scanning XAU/USD {btn['label']}…",
parse_mode=ParseMode.MARKDOWN,
)
await _safe_edit(query, f"⏳ Scanning XAU/USD {btn['label']}…")
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}"
text = f"❌ Scan failed: {_escape_md(str(exc))}"
await query.edit_message_text(
text,
parse_mode=ParseMode.MARKDOWN,
reply_markup=scan_keyboard(),
)
await _safe_edit(query, text, reply_markup=main_menu_keyboard())
async def cb_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Interactive menu actions: autoscan toggle, quality cycle, status, help."""
global _alert_min_quality
query = update.callback_query
action = query.data
if action == "menu_autoscan":
chat_id = query.message.chat.id
if chat_id in _autoscan_chats:
_autoscan_chats.discard(chat_id)
if not _autoscan_chats:
_stop_scheduler()
await query.answer("Auto-scan stopped")
else:
_autoscan_chats.add(chat_id)
_start_scheduler(context.bot)
await query.answer("Auto-scan started")
try:
await query.edit_message_reply_markup(reply_markup=main_menu_keyboard())
except BadRequest:
pass
elif action == "menu_quality":
i = _ALERT_QUALITY_CYCLE.index(_alert_min_quality) if _alert_min_quality in _ALERT_QUALITY_CYCLE else 0
_alert_min_quality = _ALERT_QUALITY_CYCLE[(i + 1) % len(_ALERT_QUALITY_CYCLE)]
await query.answer(f"Alerting {_alert_min_quality}+ signals")
try:
await query.edit_message_reply_markup(reply_markup=main_menu_keyboard())
except BadRequest:
pass
elif action == "menu_status":
await query.answer()
await query.message.reply_text(_status_text(), parse_mode=ParseMode.MARKDOWN)
elif action == "menu_help":
await query.answer()
await query.message.reply_text(_HELP_TEXT, parse_mode=ParseMode.MARKDOWN)
async def _cb_scan_all(query) -> None:
await query.edit_message_text(
"⏳ Scanning XAU/USD across M5 / M15 / H1 / H4…",
parse_mode=ParseMode.MARKDOWN,
)
await _safe_edit(query, "⏳ Scanning XAU/USD across M5 / M15 / H1 / H4…")
tfs = [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4]
tasks = [run_signal(XAUUSD, tf) for tf in tfs]
@@ -803,11 +978,7 @@ async def _cb_scan_all(query) -> None:
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(),
)
await _safe_edit(query, "\n".join(lines), reply_markup=main_menu_keyboard())
# ── Boot-time auto-start ──────────────────────────────────────────────
@@ -908,6 +1079,7 @@ def main() -> None:
app.add_handler(CommandHandler("cache", cmd_cache))
app.add_handler(CommandHandler("clearcache", cmd_clearcache))
app.add_handler(CallbackQueryHandler(cb_scan, pattern="^scan_"))
app.add_handler(CallbackQueryHandler(cb_menu, pattern="^menu_"))
logger.info("Bot running — press Ctrl+C to stop")
app.run_polling(allowed_updates=Update.ALL_TYPES)