Live path: config-driven multi-pair scanning (Telegram bot + CLI)

- telegram_bot: SCAN_PAIRS/_WS_TO_PAIR/SCAN_BUTTONS all derive from
  PlatformConfig.scan_pairs (the single pair on/off switch); buttons and
  keyboards are generated per pair; _run_signal_sync builds per-pair
  configs via pair_signals factories (was: XAU defaults for any symbol);
  scan-all works per pair; PB badge matches any *_PB tag; daily-rotating
  file logs in logs/telegram.log (30-day retention)
- main.py: --pair argument (XAUUSD | EURUSD + aliases) routed through
  pair_signals factories; price display is precision-aware (2dp gold,
  5dp FX)

scan_pairs default stays [XAUUSD] until the EUR M5 re-tune validates.
This commit is contained in:
Harwinder Sekhon
2026-07-10 21:13:42 -04:00
parent 6546b89d6b
commit 74829541d6
3 changed files with 150 additions and 58 deletions
+92 -46
View File
@@ -26,6 +26,7 @@ Usage:
import asyncio
import json
import logging
import logging.handlers
import os
import signal
import sys
@@ -51,17 +52,26 @@ from config import (
XAUUSD, PAIR_INFO,
)
from data_provider import ForexDataProvider
from pair_signals import PAIR_PRODUCERS
from signal_aggregator import SignalAggregator, SignalReport
# ── Force UTF-8 on Windows ────────────────────────────────────────────
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
level=logging.INFO,
datefmt="%H:%M:%S",
_LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
os.makedirs(_LOG_DIR, exist_ok=True)
_log_formatter = logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%H:%M:%S"
)
_console_handler = logging.StreamHandler()
_console_handler.setFormatter(_log_formatter)
_file_handler = logging.handlers.TimedRotatingFileHandler(
os.path.join(_LOG_DIR, "telegram.log"),
when="midnight", backupCount=30, encoding="utf-8",
)
_file_handler.setFormatter(_log_formatter)
logging.basicConfig(level=logging.INFO, handlers=[_console_handler, _file_handler])
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
logger = logging.getLogger("telegram_bot")
@@ -70,12 +80,15 @@ TOKEN = os.environ.get("TELEGRAM_TOKEN", "8228251480:AAFW2OAyQ2idMicql2t_CIXk1r9
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6025899022")
# ── Pairs & buttons ───────────────────────────────────────────────────
# The scanned pair list is config-driven: PlatformConfig.scan_pairs is
# the single switch for single- vs multi-pair operation. Buttons, WS
# subscriptions, and scan loops all derive from it.
SCAN_PAIRS = [XAUUSD]
SCAN_PAIRS = list(PlatformConfig().scan_pairs)
_WS_TO_PAIR = {
"XAU/USD": XAUUSD,
}
_PAIRS_LABEL = " + ".join(PAIR_INFO[p]["display"] for p in SCAN_PAIRS)
_WS_TO_PAIR = {PAIR_INFO[p]["display"]: p for p in SCAN_PAIRS}
_WS_SYMBOLS = list(_WS_TO_PAIR.keys())
_LEVEL_KEYS = (
@@ -85,14 +98,36 @@ _LEVEL_KEYS = (
"kc_upper", "kc_lower",
)
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"},
{"cb": "scan_xauusd_h1", "symbol": XAUUSD, "tf": Timeframe.H1, "label": "1 Hour"},
{"cb": "scan_xauusd_h4", "symbol": XAUUSD, "tf": Timeframe.H4, "label": "4 Hour"},
{"cb": "scan_xauusd_all", "symbol": XAUUSD, "tf": None, "label": "Scan All TFs"},
_TF_BUTTON_LABELS = [
(Timeframe.M5, "5 Min"),
(Timeframe.M15, "15 Min"),
(Timeframe.H1, "1 Hour"),
(Timeframe.H4, "4 Hour"),
]
def _build_scan_buttons() -> List[dict]:
"""One row of TF buttons + a scan-all button per scanned pair."""
multi = len(SCAN_PAIRS) > 1
buttons = []
for symbol in SCAN_PAIRS:
slug = symbol.replace("/", "").lower()
base = PAIR_INFO[symbol]["display"].split("/")[0] # XAU, EUR
prefix = f"{base} " if multi else ""
for tf, label in _TF_BUTTON_LABELS:
buttons.append({
"cb": f"scan_{slug}_{tf.value}", "symbol": symbol,
"tf": tf, "label": f"{prefix}{label}",
})
buttons.append({
"cb": f"scan_{slug}_all", "symbol": symbol, "tf": None,
"label": f"Scan All TFs — {base}" if multi else "Scan All TFs",
})
return buttons
SCAN_BUTTONS = _build_scan_buttons()
SCAN_LOOKUP = {b["cb"]: b for b in SCAN_BUTTONS}
TF_CONFIRMATIONS = {
@@ -258,7 +293,7 @@ def format_signal_message(report: SignalReport) -> str:
# TP1 is advisory, the trail is the exit). Pullback setups run
# tighter stops and arm later.
sig_cfg = PlatformConfig().signals
is_pb = getattr(ts, "strategy", "GENERIC") == "XAU_PB"
is_pb = getattr(ts, "strategy", "GENERIC").endswith("_PB")
arm_r = sig_cfg.pb_trail_arm_r if is_pb else sig_cfg.trail_arm_r
dist_r = sig_cfg.pb_trail_dist_r if is_pb else sig_cfg.trail_dist_r
if arm_r > 0:
@@ -312,29 +347,31 @@ def _format_scan_summary(reports: List[SignalReport], tf: Timeframe) -> str:
# ── Keyboard helper ───────────────────────────────────────────────────
def _scan_button_rows() -> List[List[InlineKeyboardButton]]:
"""Per pair: one row of TF buttons + one scan-all row."""
rows = []
for symbol in SCAN_PAIRS:
tf_buttons = [b for b in SCAN_BUTTONS
if b["symbol"] == symbol and b["tf"] is not None]
rows.append([InlineKeyboardButton(b["label"], callback_data=b["cb"])
for b in tf_buttons])
all_button = next((b for b in SCAN_BUTTONS
if b["symbol"] == symbol and b["tf"] is None), None)
if all_button:
rows.append([InlineKeyboardButton(all_button["label"],
callback_data=all_button["cb"])])
return rows
def scan_keyboard() -> InlineKeyboardMarkup:
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)
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"])])
return InlineKeyboardMarkup(rows)
return InlineKeyboardMarkup(_scan_button_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 = _scan_button_rows()
rows.append([
InlineKeyboardButton(autoscan_label, callback_data="menu_autoscan"),
InlineKeyboardButton(quality_label, callback_data="menu_quality"),
@@ -374,9 +411,16 @@ def _run_signal_sync(
confirmation_tfs = TF_CONFIRMATIONS.get(
timeframe, [Timeframe.M15, Timeframe.H4]
)
config = PlatformConfig()
config.primary_timeframe = timeframe
config.confirmation_timeframes = confirmation_tfs
# Per-pair tuned configs (regime weights, gates, sessions) come from
# the pair_signals factories; a plain PlatformConfig would silently
# run every pair with XAU defaults.
factory = PAIR_PRODUCERS.get(symbol)
if factory is not None:
config = factory(timeframe, confirmation_tfs)
else:
config = PlatformConfig()
config.primary_timeframe = timeframe
config.confirmation_timeframes = confirmation_tfs
aggregator = SignalAggregator(config)
aggregator.data_provider = _get_shared_dp()
return aggregator.analyze_pair(symbol=symbol)
@@ -717,7 +761,7 @@ def _stop_scheduler() -> None:
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
scanning = "running" if _autoscan_chats else "off"
text = (
"👋 *ForexBot2 — XAU/USD Signal Intelligence*\n\n"
f"👋 *ForexBot2 — {_PAIRS_LABEL} Signal Intelligence*\n\n"
"Pick a timeframe to scan gold.\n"
"Signals include a full trade setup with entry, SL and TP.\n\n"
f"Auto-scan: *{scanning}* — /autoscan to toggle\n"
@@ -735,7 +779,7 @@ async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
_HELP_TEXT = (
"📖 *ForexBot2 Commands*\n\n"
"/start — Welcome & interactive menu\n"
"/scan — Quick XAU/USD scan menu\n"
"/scan — Quick 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"
@@ -868,7 +912,7 @@ async def cmd_autoscan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
next_h4 = _seconds_until_boundary(240, buffer=10)
await update.message.reply_text(
"✅ *Auto-scan enabled*\n\n"
f"Scanning XAU/USD on M5/M15/H1/H4 at candle close.\n"
f"Scanning {_PAIRS_LABEL} on M5/M15/H1/H4 at candle close.\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"
@@ -879,7 +923,7 @@ async def cmd_autoscan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
async def cmd_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(
"🔍 *XAU/USD — Pick a timeframe*",
f"🔍 *{_PAIRS_LABEL} — Pick a timeframe*",
parse_mode=ParseMode.MARKDOWN,
reply_markup=main_menu_keyboard(),
)
@@ -894,10 +938,11 @@ async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
return
if btn["tf"] is None:
await _cb_scan_all(query)
await _cb_scan_all(query, btn["symbol"])
return
await _safe_edit(query, f"⏳ Scanning XAU/USD {btn['label']}…")
display = PAIR_INFO[btn["symbol"]]["display"]
await _safe_edit(query, f"⏳ Scanning {display} {btn['label']}…")
try:
report = await run_signal(btn["symbol"], btn["tf"])
@@ -949,11 +994,12 @@ async def cb_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await query.message.reply_text(_HELP_TEXT, parse_mode=ParseMode.MARKDOWN)
async def _cb_scan_all(query) -> None:
await _safe_edit(query, "⏳ Scanning XAU/USD across M5 / M15 / H1 / H4…")
async def _cb_scan_all(query, symbol: str = XAUUSD) -> None:
display = PAIR_INFO[symbol]["display"]
await _safe_edit(query, f"⏳ Scanning {display} across M5 / M15 / H1 / H4…")
tfs = [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4]
tasks = [run_signal(XAUUSD, tf) for tf in tfs]
tasks = [run_signal(symbol, tf) for tf in tfs]
results = await asyncio.gather(*tasks, return_exceptions=True)
reports = []
@@ -968,7 +1014,7 @@ async def _cb_scan_all(query) -> None:
_update_level_cache(reports)
lines = [
"📊 *XAU/USD — All Timeframes*",
f"📊 *{display} — All Timeframes*",
f"🕐 {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
"",
]
@@ -1008,7 +1054,7 @@ async def _post_init(app: Application) -> None:
"""Register command menu and start scheduler on boot."""
await app.bot.set_my_commands([
("start", "Welcome & scan buttons"),
("scan", "Quick XAU/USD scan buttons"),
("scan", "Quick scan buttons"),
("autoscan", "Toggle auto-scan on/off"),
("status", "Bot status, WS connection, live prices"),
("cache", "Show data cache age per pair/TF"),
@@ -1056,7 +1102,7 @@ async def _post_init(app: Application) -> None:
await app.bot.send_message(
chat_id,
f"🤖 *ForexBot2 started*\n\n"
f"Auto-scanning XAU/USD on M5/M15/H1/H4.\n"
f"Auto-scanning {_PAIRS_LABEL} on M5/M15/H1/H4.\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"