From 538977115bd13b993144081e641163edb43be486 Mon Sep 17 00:00:00 2001 From: Harwinder Sekhon Date: Fri, 10 Jul 2026 21:13:42 -0400 Subject: [PATCH] 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. Co-Authored-By: Claude Fable 5 --- main.py | 47 +++++++++++++---- telegram_bot.py | 138 ++++++++++++++++++++++++++++++++---------------- test_all.py | 23 +++++++- 3 files changed, 150 insertions(+), 58 deletions(-) diff --git a/main.py b/main.py index bf6965f..afb6d37 100755 --- a/main.py +++ b/main.py @@ -21,7 +21,8 @@ if hasattr(sys.stdout, "reconfigure"): if hasattr(sys.stderr, "reconfigure"): sys.stderr.reconfigure(encoding="utf-8") -from config import PlatformConfig, Timeframe, SignalStrength, XAUUSD +from config import PlatformConfig, Timeframe, SignalStrength, XAUUSD, EURUSD +from pair_signals import PAIR_PRODUCERS from signal_aggregator import SignalAggregator, SignalReport @@ -97,6 +98,11 @@ def _compact_reason(reason: str) -> str: return s.strip() +def _fmt_price(price: float) -> str: + """2dp for gold-scale prices, 5dp for FX-scale (EUR/USD etc.).""" + return f"{price:.2f}" if price >= 10 else f"{price:.5f}" + + def print_signal_report(report: SignalReport, detail: bool = False): sc = signal_color(report.final_signal) qc = quality_color(report.signal_quality) @@ -116,16 +122,16 @@ def print_signal_report(report: SignalReport, detail: bool = False): ts = report.trade_setup dc = Colors.GREEN if ts.direction == "BUY" else Colors.RED print(f"\n {Colors.BOLD}Setup{Colors.RESET} " - f"{dc}{ts.direction}{Colors.RESET} @ {Colors.BOLD}{ts.entry_price:.3f}{Colors.RESET} " - f"→ SL {Colors.RED}{ts.stop_loss:.3f}{Colors.RESET} " + f"{dc}{ts.direction}{Colors.RESET} @ {Colors.BOLD}{_fmt_price(ts.entry_price)}{Colors.RESET} " + f"→ SL {Colors.RED}{_fmt_price(ts.stop_loss)}{Colors.RESET} " f"({ts.risk_pips:.1f}p) " - f"→ TP {Colors.GREEN}{ts.take_profit_1:.3f}{Colors.RESET} " + f"→ TP {Colors.GREEN}{_fmt_price(ts.take_profit_1)}{Colors.RESET} " f"({ts.reward_pips:.1f}p) " f"RR 1:{ts.risk_reward:.1f}") # ── 3. CONTEXT (price · regime · vol · MTF breakdown) ────────── print(f"\n {Colors.WHITE}{report.display_name}{Colors.RESET} " - f"{Colors.BOLD}{report.current_price:.3f}{Colors.RESET} · " + f"{Colors.BOLD}{_fmt_price(report.current_price)}{Colors.RESET} · " f"{Colors.CYAN}{report.market_regime}{Colors.RESET} · " f"vol {report.volatility_regime}") @@ -250,6 +256,8 @@ Examples: python main.py --json JSON output """, ) + parser.add_argument("--pair", "-p", default="XAUUSD", + help="Pair to analyze: XAUUSD | EURUSD (default: XAUUSD)") parser.add_argument("--timeframe", "-tf", default="H1", choices=["M5", "M15", "M30", "H1", "H4", "D1"], help="Primary analysis timeframe (default: H1)") @@ -263,14 +271,25 @@ Examples: return parser.parse_args() +def _resolve_pair(s: str) -> str: + aliases = { + "XAUUSD": XAUUSD, "XAU/USD": XAUUSD, "GOLD": XAUUSD, + "EURUSD": EURUSD, "EUR/USD": EURUSD, "EUR": EURUSD, + } + key = s.upper().strip() + if key in aliases: + return aliases[key] + raise SystemExit(f"Unknown pair '{s}'. Try one of: {', '.join(sorted(aliases))}") + + def build_config(args) -> PlatformConfig: - config = PlatformConfig() + pair = _resolve_pair(args.pair) tf_map = { "M5": Timeframe.M5, "M15": Timeframe.M15, "M30": Timeframe.M30, "H1": Timeframe.H1, "H4": Timeframe.H4, "D1": Timeframe.D1, } - config.primary_timeframe = tf_map[args.timeframe] + primary_tf = tf_map[args.timeframe] tf_confirmations = { "M5": [Timeframe.M1, Timeframe.M15], @@ -280,23 +299,31 @@ def build_config(args) -> PlatformConfig: "H4": [Timeframe.H1, Timeframe.D1], "D1": [Timeframe.H4, Timeframe.W1], } - config.confirmation_timeframes = tf_confirmations.get( + confirmation_tfs = tf_confirmations.get( args.timeframe, [Timeframe.M15, Timeframe.H4] ) + # Per-pair tuned configs from the pair_signals factories. + factory = PAIR_PRODUCERS.get(pair) + if factory is not None: + return factory(primary_tf, confirmation_tfs) + + config = PlatformConfig() + config.primary_timeframe = primary_tf + config.confirmation_timeframes = confirmation_tfs return config def run_live(args, config: PlatformConfig): print(f"{Colors.GRAY} Fetching market data and running analysis...{Colors.RESET}\n") aggregator = SignalAggregator(config) - report = aggregator.analyze_pair() + report = aggregator.analyze_pair(symbol=config.symbol) if args.json: print(json.dumps(report_to_dict(report), indent=2)) return - print_header("XAU/USD SIGNAL INTELLIGENCE") + print_header(f"{config.display_name} SIGNAL INTELLIGENCE") print_signal_report(report, detail=args.detail) print(f"\n{Colors.CYAN}{'═' * 80}{Colors.RESET}") print(f"{Colors.GRAY} Disclaimer: For educational/research purposes only.{Colors.RESET}") diff --git a/telegram_bot.py b/telegram_bot.py index 9e6bf58..5ffec6b 100755 --- a/telegram_bot.py +++ b/telegram_bot.py @@ -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" diff --git a/test_all.py b/test_all.py index 123ddf9..166bb1f 100644 --- a/test_all.py +++ b/test_all.py @@ -1415,6 +1415,7 @@ def _(): @test("build_config sets correct timeframe and confirmations") def _(): class FakeArgs: + pair = "XAUUSD" timeframe = "H4" config = build_config(FakeArgs()) assert config.primary_timeframe == Timeframe.H4 @@ -1426,11 +1427,23 @@ def _(): def _(): for tf_str in ["M5", "M15", "M30", "H1", "H4", "D1"]: class FakeArgs: + pair = "XAUUSD" timeframe = tf_str config = build_config(FakeArgs()) assert config.primary_timeframe is not None +@test("build_config resolves EURUSD pair to EUR factory config") +def _(): + class FakeArgs: + pair = "EURUSD" + timeframe = "M15" + config = build_config(FakeArgs()) + assert config.symbol == "EUR/USD" + assert config.pip_value == 0.0001 + assert config.primary_timeframe == Timeframe.M15 + + # ══════════════════════════════════════════════════════════════════════ # MODULE 7: telegram_bot.py # ══════════════════════════════════════════════════════════════════════ @@ -1609,10 +1622,13 @@ def _(): @test("SCAN_BUTTONS and SCAN_LOOKUP consistency") def _(): - assert len(SCAN_BUTTONS) == 5 # 4 TFs + "Scan All" + from telegram_bot import SCAN_PAIRS + # 4 TF buttons + one "Scan All" per scanned pair + assert len(SCAN_BUTTONS) == 5 * len(SCAN_PAIRS) for btn in SCAN_BUTTONS: assert btn["cb"] in SCAN_LOOKUP assert "symbol" in btn + assert btn["symbol"] in SCAN_PAIRS assert "tf" in btn assert "label" in btn @@ -1642,8 +1658,11 @@ def _(): _level_cache_ts.clear() -@test("_WS_TO_PAIR mapping is correct") +@test("_WS_TO_PAIR mapping covers every scanned pair") def _(): + from telegram_bot import SCAN_PAIRS + for p in SCAN_PAIRS: + assert _WS_TO_PAIR[PAIR_INFO[p]["display"]] == p assert _WS_TO_PAIR["XAU/USD"] == XAUUSD