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.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Harwinder
2026-05-20 12:45:27 -04:00
parent b61818330b
commit 643952a8e2
4 changed files with 12 additions and 183 deletions
+3 -5
View File
@@ -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
+5 -9
View File
@@ -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.
+4 -158
View File
@@ -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")
-11
View File
@@ -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
# ══════════════════════════════════════════════════════════════════════