From c46bb85012496e11104fc7e2f244e81b5f9ee5b4 Mon Sep 17 00:00:00 2001 From: Harwinder Date: Wed, 20 May 2026 12:45:27 -0400 Subject: [PATCH] Enable M5 auto-scan alerts and remove live dashboard M5 already broadcast through the shared _scan_timeframe path but was documented as data-only (docstring, CLAUDE.md, DOCUMENTATION.md, plus a phantom `if tf == M5: return` step that never existed in code). Align all docs/comments so M5 alerting at C+ grade is the intended, documented behavior. Remove the live dashboard entirely per request: the 30s refresh loop, per-scan refresh, boot send, and the dashboard-only stats layer (_signal_stats / _stats_updated / _update_signal_stats and its call sites, _format_stats_dashboard and its helper constants). Drop now-unused ZoneInfo import and dashboard mentions from help/autoscan/boot messages and the test suite. The WebSocket level-breach monitor and level cache are untouched. Tests: 136 passed, 0 failed. --- CLAUDE.md | 8 +-- DOCUMENTATION.md | 14 ++-- telegram_bot.py | 162 ++--------------------------------------------- test_all.py | 11 ---- 4 files changed, 12 insertions(+), 183 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e109cb3..b4a37b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,8 +18,7 @@ Twelve Data REST API + WebSocket telegram_bot.py main.py (Telegram alerts, (CLI single-pair auto-scanner, analysis) - WS monitor, - live dashboard) + WS monitor) ``` ## Core Files @@ -31,7 +30,7 @@ telegram_bot.py main.py | `engine.py` | `TechnicalEngine` — computes EMA, RSI, BB, ADX, ATR, MACD, ROC, Keltner, divergences, candlestick patterns, swing S/R | | `signal_aggregator.py` | `SignalAggregator` — MTF analysis, news integration, diminishing penalty cap, trade setup generation | | `news_engine.py` | Economic calendar (Forex Factory JSON) + RSS sentiment (keyword-based) | -| `telegram_bot.py` | Async Telegram bot with auto-scanner (M5/M15/H1/H4), WebSocket price monitor, live dashboard | +| `telegram_bot.py` | Async Telegram bot with auto-scanner (M5/M15/H1/H4), WebSocket price monitor | | `main.py` | CLI entry point for XAU/USD analysis | | `test_all.py` | Comprehensive QA suite (~137 tests, no live API needed) | @@ -91,10 +90,9 @@ python backtest/gpu_backtest.py --pair XAUUSD --days 365 ## Telegram Bot -- **Auto-scan:** M5 (data-only, no alerts), M15, H1, H4 at candle boundaries + 10s buffer +- **Auto-scan:** M5, M15, H1, H4 at candle boundaries + 10s buffer (all timeframes alert) - **Manual scan:** Buttons for M15/H1/H4 individual scans + "Scan All TFs" multi-timeframe summary - **WebSocket:** Streams live ticks for XAU/USD; triggers immediate M15 scan on key level breach (swing S/R, BB, KC, EMA200) with 5-min cooldown -- **Live dashboard:** Edit-in-place message, 30s refresh, shows XAU/USD across timeframes - **Commands:** `/start`, `/scan`, `/autoscan`, `/status`, `/cache`, `/clearcache`, `/help` ## Backtesting diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 5a4645d..4e85fb6 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -22,12 +22,12 @@ The bot looks at the same pair across multiple zoom levels, like looking at a ma | Timeframe | What It Shows | How Often It Scans | |-----------|--------------|-------------------| -| 5-Minute (M5) | Very short-term price action | Every 5 minutes — **data collection only**, no alerts | +| 5-Minute (M5) | Very short-term price action | Every 5 minutes | | 15-Minute (M15) | Short-term moves | Every 15 minutes | | 1-Hour (H1) | Intraday trends | Every hour | | 4-Hour (H4) | Swing-level trends | Every 4 hours | -The M5 scan runs silently in the background — it gathers data to keep the bot's internal picture fresh, but it does not send you any messages. Only M15, H1, and H4 scans produce alerts. +All four timeframes (M5, M15, H1, H4) produce alerts at candle close when the signal grade is C or better. ## How Does It Decide What To Signal? @@ -911,12 +911,8 @@ def _run_signal_sync(symbol, timeframe, confirmation_tfs): 1. For each symbol in SCAN_PAIRS: report = await run_signal(symbol, tf, confirmation_tfs) 2. _update_level_cache(reports) # Refresh cached levels for WS breach detection -3. If tf == M5: return # Data only, no Telegram output -4. Filter actionable: quality in (A+, A, B, C) -5. If none actionable: log and return silently -6. Format scan summary (one-liner per pair) -7. For each autoscan chat: - Send summary message +3. Filter actionable: quality in (A+, A, B, C) # applies to every timeframe, M5 included +4. For each autoscan chat: Send detail message per actionable signal (includes trade setup) ``` @@ -1029,7 +1025,7 @@ Terminal output includes color-coded sections: 3. **WebSocket for XAU/USD only:** The bot streams live prices via WebSocket for real-time level breach detection on gold. -4. **M5 is data-only:** The M5 scan runs every 5 minutes but only updates the level cache (used by WebSocket breach detection). It does not send Telegram messages — this reduces noise while keeping the internal picture current. +4. **M5 alerts like every timeframe:** The M5 scan runs every 5 minutes, refreshes the level cache (used by WebSocket breach detection), and sends a Telegram alert whenever the signal grade is C or better — the same path as M15/H1/H4. 5. **Graceful degradation everywhere:** If news APIs fail, signals proceed without fundamental context. If WebSocket disconnects, it reconnects with exponential backoff. If a single pair's scan fails, others continue. diff --git a/telegram_bot.py b/telegram_bot.py index 2bdfc97..f81412a 100755 --- a/telegram_bot.py +++ b/telegram_bot.py @@ -8,7 +8,7 @@ real-time WebSocket level-breach monitor. Manual scan: XAU/USD × 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). + M5 fires at :00/:05/:10/… + 10s buffer. M15 fires at :00/:15/:30/:45 + 10s buffer. H1 fires at :00 + 10s buffer. H4 fires at :00 every 4 hours + 10s buffer. @@ -32,7 +32,6 @@ 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 @@ -85,11 +84,6 @@ _LEVEL_KEYS = ( "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": "5 Min"}, {"cb": "scan_xauusd_m15", "symbol": XAUUSD, "tf": Timeframe.M15, "label": "15 Min"}, @@ -275,136 +269,6 @@ def _format_scan_summary(reports: List[SignalReport], tf: Timeframe) -> str: 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: @@ -487,9 +351,6 @@ async def _scan_timeframe( 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): @@ -571,7 +432,6 @@ async def _handle_breach(bot, ws_symbol: str, pair_symbol: str, price: float, le 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}") @@ -667,7 +527,7 @@ _ws_task: Optional[asyncio.Task] = None def _start_scheduler(bot) -> None: - global _ws_task, _dashboard_task + global _ws_task alive = [t for t in _autoscan_tasks if not t.done()] if not alive: _autoscan_tasks.clear() @@ -681,27 +541,19 @@ def _start_scheduler(bot) -> None: _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 + 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 - 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") + logger.info("Scheduler and WebSocket monitor stopped") # ── Handlers ────────────────────────────────────────────────────────── @@ -735,7 +587,6 @@ async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: "/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) @@ -852,7 +703,6 @@ async def cmd_autoscan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No await update.message.reply_text( "✅ *Auto-scan enabled*\n\n" f"Scanning XAU/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" @@ -917,7 +767,6 @@ async def _cb_scan_all(query) -> None: errors.append(f"❌ {tf.value}: {result}") else: reports.append(result) - _update_signal_stats([result], tf) if reports: _update_level_cache(reports) @@ -996,7 +845,6 @@ async def _post_init(app: Application) -> None: 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") @@ -1017,14 +865,12 @@ async def _post_init(app: Application) -> None: chat_id, f"🤖 *ForexBot2 started*\n\n" f"Auto-scanning XAU/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") diff --git a/test_all.py b/test_all.py index 14f7f0b..c8dcb41 100644 --- a/test_all.py +++ b/test_all.py @@ -1322,7 +1322,6 @@ from telegram_bot import ( _escape_md, _signal_label, _mtf_line, format_signal_message, _format_scan_summary, _seconds_until_boundary, _check_level_breach, - _format_stats_dashboard, scan_keyboard, SCAN_BUTTONS, SCAN_LOOKUP, _update_level_cache, _last_prices, _level_cache, _level_cache_ts, _WS_TO_PAIR, @@ -1529,16 +1528,6 @@ def _(): assert _WS_TO_PAIR["XAU/USD"] == XAUUSD -@test("_format_stats_dashboard returns waiting message when empty") -def _(): - import telegram_bot - old_stats = telegram_bot._signal_stats.copy() - telegram_bot._signal_stats.clear() - text = _format_stats_dashboard() - assert "Waiting" in text or "waiting" in text.lower() - telegram_bot._signal_stats.update(old_stats) - - # ══════════════════════════════════════════════════════════════════════ # MODULE 8: Integration / Cross-module tests # ══════════════════════════════════════════════════════════════════════