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:
+208
-36
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user