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.
This commit is contained in:
Harwinder
2026-05-20 12:45:27 -04:00
parent cc53448c34
commit c46bb85012
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_bot.py main.py
(Telegram alerts, (CLI single-pair (Telegram alerts, (CLI single-pair
auto-scanner, analysis) auto-scanner, analysis)
WS monitor, WS monitor)
live dashboard)
``` ```
## Core Files ## 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 | | `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 | | `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) | | `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 | | `main.py` | CLI entry point for XAU/USD analysis |
| `test_all.py` | Comprehensive QA suite (~137 tests, no live API needed) | | `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 ## 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 - **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 - **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` - **Commands:** `/start`, `/scan`, `/autoscan`, `/status`, `/cache`, `/clearcache`, `/help`
## Backtesting ## 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 | | 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 | | 15-Minute (M15) | Short-term moves | Every 15 minutes |
| 1-Hour (H1) | Intraday trends | Every hour | | 1-Hour (H1) | Intraday trends | Every hour |
| 4-Hour (H4) | Swing-level trends | Every 4 hours | | 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? ## 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: 1. For each symbol in SCAN_PAIRS:
report = await run_signal(symbol, tf, confirmation_tfs) report = await run_signal(symbol, tf, confirmation_tfs)
2. _update_level_cache(reports) # Refresh cached levels for WS breach detection 2. _update_level_cache(reports) # Refresh cached levels for WS breach detection
3. If tf == M5: return # Data only, no Telegram output 3. Filter actionable: quality in (A+, A, B, C) # applies to every timeframe, M5 included
4. Filter actionable: quality in (A+, A, B, C) 4. For each autoscan chat:
5. If none actionable: log and return silently
6. Format scan summary (one-liner per pair)
7. For each autoscan chat:
Send summary message
Send detail message per actionable signal (includes trade setup) 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. 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. 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. Manual scan: XAU/USD × 3 timeframes (M15/H1/H4) via inline buttons.
Auto-scan: Starts automatically on boot if TELEGRAM_CHAT_ID is set. Auto-scan: Starts automatically on boot if TELEGRAM_CHAT_ID is set.
/autoscan toggles for additional chats or to stop. /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. M15 fires at :00/:15/:30/:45 + 10s buffer.
H1 fires at :00 + 10s buffer. H1 fires at :00 + 10s buffer.
H4 fires at :00 every 4 hours + 10s buffer. H4 fires at :00 every 4 hours + 10s buffer.
@@ -32,7 +32,6 @@ import sys
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from typing import Dict, List, Optional, Set from typing import Dict, List, Optional, Set
import websockets import websockets
@@ -85,11 +84,6 @@ _LEVEL_KEYS = (
"kc_upper", "kc_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 = [ SCAN_BUTTONS = [
{"cb": "scan_xauusd_m5", "symbol": XAUUSD, "tf": Timeframe.M5, "label": "5 Min"}, {"cb": "scan_xauusd_m5", "symbol": XAUUSD, "tf": Timeframe.M5, "label": "5 Min"},
{"cb": "scan_xauusd_m15", "symbol": XAUUSD, "tf": Timeframe.M15, "label": "15 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) 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 ─────────────────────────────────────────────────── # ── Keyboard helper ───────────────────────────────────────────────────
def scan_keyboard() -> InlineKeyboardMarkup: def scan_keyboard() -> InlineKeyboardMarkup:
@@ -487,9 +351,6 @@ async def _scan_timeframe(
return return
_update_level_cache(reports) _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")] actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B", "C")]
for chat_id in list(_autoscan_chats): 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: try:
report = await run_signal(pair_symbol, Timeframe.M15) report = await run_signal(pair_symbol, Timeframe.M15)
_update_level_cache([report]) _update_level_cache([report])
_update_signal_stats([report], Timeframe.M15)
except Exception: except Exception:
logger.exception(f"Breach scan failed for {display}") logger.exception(f"Breach scan failed for {display}")
@@ -667,7 +527,7 @@ _ws_task: Optional[asyncio.Task] = None
def _start_scheduler(bot) -> 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()] alive = [t for t in _autoscan_tasks if not t.done()]
if not alive: if not alive:
_autoscan_tasks.clear() _autoscan_tasks.clear()
@@ -681,27 +541,19 @@ def _start_scheduler(bot) -> None:
_ws_task = asyncio.create_task(_ws_monitor(bot)) _ws_task = asyncio.create_task(_ws_monitor(bot))
logger.info("WebSocket level-breach monitor started") 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: def _stop_scheduler() -> None:
global _ws_task, _dashboard_task global _ws_task
for t in _autoscan_tasks: for t in _autoscan_tasks:
t.cancel() t.cancel()
_autoscan_tasks.clear() _autoscan_tasks.clear()
if _ws_task and not _ws_task.done(): if _ws_task and not _ws_task.done():
_ws_task.cancel() _ws_task.cancel()
_ws_task = None _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): for t in list(_breach_tasks):
t.cancel() t.cancel()
_breach_tasks.clear() _breach_tasks.clear()
logger.info("Scheduler, WebSocket, and dashboard loop stopped") logger.info("Scheduler and WebSocket monitor stopped")
# ── Handlers ────────────────────────────────────────────────────────── # ── Handlers ──────────────────────────────────────────────────────────
@@ -735,7 +587,6 @@ async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"/clearcache — Wipe data cache, force fresh fetch\n" "/clearcache — Wipe data cache, force fresh fetch\n"
"/help — This message\n\n" "/help — This message\n\n"
"*Auto-scan* fires at candle close: M5/M15/H1/H4.\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." "*WebSocket* monitors live prices and triggers breach scans."
) )
await update.message.reply_text(text, parse_mode=ParseMode.MARKDOWN) 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( await update.message.reply_text(
"✅ *Auto-scan enabled*\n\n" "✅ *Auto-scan enabled*\n\n"
f"Scanning XAU/USD on M5/M15/H1/H4 at candle close.\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"📡 Live price monitoring active.\n"
f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | " 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"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}") errors.append(f"❌ {tf.value}: {result}")
else: else:
reports.append(result) reports.append(result)
_update_signal_stats([result], tf)
if reports: if reports:
_update_level_cache(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}") logger.error(f"Initial scan failed for {SCAN_PAIRS[i]}: {r}")
if init_reports: if init_reports:
_update_level_cache(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") logger.info(f"Level cache populated: {sum(len(v) for v in _level_cache.values())} levels")
except Exception: except Exception:
logger.exception("Initial scan failed — WS will wait for first candle scan") 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, chat_id,
f"🤖 *ForexBot2 started*\n\n" f"🤖 *ForexBot2 started*\n\n"
f"Auto-scanning XAU/USD on M5/M15/H1/H4.\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"📡 Live price monitoring active.\n"
f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | " 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"H1 {next_h1 / 60:.0f}m | H4 {next_h4 / 60:.0f}m\n\n"
f"/autoscan to stop", f"/autoscan to stop",
parse_mode=ParseMode.MARKDOWN, parse_mode=ParseMode.MARKDOWN,
) )
await _send_dashboard(app.bot, chat_id)
except Exception: except Exception:
logger.exception("Failed to send boot message") logger.exception("Failed to send boot message")
-11
View File
@@ -1322,7 +1322,6 @@ from telegram_bot import (
_escape_md, _signal_label, _mtf_line, _escape_md, _signal_label, _mtf_line,
format_signal_message, _format_scan_summary, format_signal_message, _format_scan_summary,
_seconds_until_boundary, _check_level_breach, _seconds_until_boundary, _check_level_breach,
_format_stats_dashboard,
scan_keyboard, SCAN_BUTTONS, SCAN_LOOKUP, scan_keyboard, SCAN_BUTTONS, SCAN_LOOKUP,
_update_level_cache, _last_prices, _level_cache, _level_cache_ts, _update_level_cache, _last_prices, _level_cache, _level_cache_ts,
_WS_TO_PAIR, _WS_TO_PAIR,
@@ -1529,16 +1528,6 @@ def _():
assert _WS_TO_PAIR["XAU/USD"] == XAUUSD 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 # MODULE 8: Integration / Cross-module tests
# ══════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════