From a708609f8be08cebe6b2ecc02983c99b57364522 Mon Sep 17 00:00:00 2001 From: Harwinder Date: Sun, 10 May 2026 23:49:36 -0400 Subject: [PATCH] Add EUR/USD support and 4-button Telegram scanner Add EURUSD pair with dynamic pair info lookup. Telegram bot now shows 4 scan buttons: EUR/USD 15M, EUR/USD 1H, USD/JPY 15M, USD/JPY 1H. Each indicator displays directional arrows in the message output. Price formatting adapts per pair (5dp for EUR, 3dp for JPY). --- config.py | 16 ++++++-- data_provider.py | 23 ++++++------ telegram_bot.py | 98 +++++++++++++++++++++++++++++++----------------- 3 files changed, 87 insertions(+), 50 deletions(-) diff --git a/config.py b/config.py index ef2a1e0..249acec 100644 --- a/config.py +++ b/config.py @@ -1,5 +1,5 @@ """ -ForexBot2 — Configuration (USD/JPY focus) +ForexBot2 — Configuration ========================================== """ @@ -35,10 +35,18 @@ class MarketSession(Enum): OVERLAP_LONDON_NY = "london_ny_overlap" -# ── Single Focus: USD/JPY ───────────────────────────────────────── +# ── Supported pairs ────────────────────────────────────────────── USDJPY = "USDJPY=X" +EURUSD = "EURUSD=X" + +PAIR_INFO = { + USDJPY: {"display": "USD/JPY", "pip_value": 0.01, "is_jpy_pair": True}, + EURUSD: {"display": "EUR/USD", "pip_value": 0.0001, "is_jpy_pair": False}, +} + +# Defaults (kept for backward compat) DISPLAY = "USD/JPY" -PIP_VALUE = 0.01 # JPY pairs use 0.01 per pip +PIP_VALUE = 0.01 @dataclass @@ -109,7 +117,7 @@ class SignalConfig: @dataclass class NewsConfig: """Economic calendar & sentiment.""" - tracked_currencies: List[str] = field(default_factory=lambda: ["USD", "JPY"]) + tracked_currencies: List[str] = field(default_factory=lambda: ["USD", "JPY", "EUR"]) min_impact: str = "medium" pre_event_buffer_minutes: int = 30 post_event_buffer_minutes: int = 15 diff --git a/data_provider.py b/data_provider.py index 122bf38..1df3100 100644 --- a/data_provider.py +++ b/data_provider.py @@ -1,5 +1,5 @@ """ -ForexBot2 — Data Provider (Twelve Data SDK, USD/JPY only) +ForexBot2 — Data Provider (Twelve Data SDK) ========================================================== Fetches and validates OHLCV via the official `twelvedata` Python SDK. """ @@ -13,7 +13,7 @@ from typing import Dict, Optional import pandas as pd from twelvedata import TDClient -from config import PlatformConfig, Timeframe, USDJPY, DISPLAY, PIP_VALUE +from config import PlatformConfig, Timeframe, USDJPY, PAIR_INFO logger = logging.getLogger(__name__) @@ -37,6 +37,12 @@ class ForexDataProvider: Timeframe.W1: "1week", } + BARS_PER_DAY = { + Timeframe.M1: 1440, Timeframe.M5: 288, Timeframe.M15: 96, + Timeframe.M30: 48, Timeframe.H1: 24, Timeframe.H4: 6, + Timeframe.D1: 1, Timeframe.W1: 0.143, + } + RATE_LIMIT = 8 RATE_WINDOW = 60.0 @@ -103,23 +109,18 @@ class ForexDataProvider: return None def get_spread_estimate(self, symbol: str = USDJPY) -> float: - """Estimate spread from recent M5 H-L median; fallback to 1 pip on JPY.""" try: df = self.fetch_ohlcv(symbol, Timeframe.M5, lookback_days=1) if df is not None and not df.empty: return float((df["High"] - df["Low"]).median()) except Exception: pass - # Fallback: 1 pip = 0.01 for JPY pairs - return PIP_VALUE + info = PAIR_INFO.get(symbol, PAIR_INFO[USDJPY]) + return info["pip_value"] def get_pair_info(self, symbol: str = USDJPY) -> Dict: - return { - "symbol": symbol, - "display": DISPLAY, - "pip_value": PIP_VALUE, - "is_jpy_pair": True, - } + info = PAIR_INFO.get(symbol, PAIR_INFO[USDJPY]) + return {"symbol": symbol, **info} # ── Private ──────────────────────────────────────────────────── diff --git a/telegram_bot.py b/telegram_bot.py index b37bce4..f86789b 100644 --- a/telegram_bot.py +++ b/telegram_bot.py @@ -28,7 +28,7 @@ from telegram.ext import ( ContextTypes, ) -from config import PlatformConfig, SignalStrength +from config import PlatformConfig, SignalStrength, Timeframe, EURUSD, USDJPY, PAIR_INFO from signal_aggregator import SignalAggregator, SignalReport # ── Force UTF-8 on Windows ──────────────────────────────────────────── @@ -46,7 +46,14 @@ logger = logging.getLogger("telegram_bot") TOKEN = os.environ.get("TELEGRAM_TOKEN", "8228251480:AAFW2OAyQ2idMicql2t_CIXk1r9-qfRTfNE") -SCAN_CB = "scan_signal" +SCAN_BUTTONS = [ + {"cb": "scan_eurusd_m15", "symbol": EURUSD, "tf": Timeframe.M15, "label": "EUR/USD 15M"}, + {"cb": "scan_eurusd_h1", "symbol": EURUSD, "tf": Timeframe.H1, "label": "EUR/USD 1H"}, + {"cb": "scan_usdjpy_m15", "symbol": USDJPY, "tf": Timeframe.M15, "label": "USD/JPY 15M"}, + {"cb": "scan_usdjpy_h1", "symbol": USDJPY, "tf": Timeframe.H1, "label": "USD/JPY 1H"}, +] + +SCAN_LOOKUP = {b["cb"]: b for b in SCAN_BUTTONS} _executor = ThreadPoolExecutor(max_workers=2) @@ -80,14 +87,19 @@ def _mtf_line(report: SignalReport) -> str: return " | ".join(parts) -def _compact_reasons(reasons: list[str], n: int = 3) -> list[str]: - out = [] - for r in reasons[:n]: - s = r.replace("STRONG: ", "") - if " | " in s: - s = s.split(" | ", 1)[0] - out.append(s.strip()) - return out +_ARROW = { + SignalStrength.STRONG_BUY: "⬆️", + SignalStrength.BUY: "↗️", + SignalStrength.NEUTRAL: "➡️", + SignalStrength.SELL: "↘️", + SignalStrength.STRONG_SELL: "⬇️", +} + + +def _indicator_line(name: str, ind) -> str: + arrow = _ARROW.get(ind.signal, "➡️") + short = ind.description.split(" | ")[0] if " | " in ind.description else ind.description + return f"{arrow} *{ind.name}*: {short}" def format_signal_message(report: SignalReport) -> str: @@ -102,10 +114,10 @@ def format_signal_message(report: SignalReport) -> str: # Header now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") lines += [ - f"🔍 *USD/JPY Signal Analysis*", + f"🔍 *{report.display_name} Signal Analysis* ({report.primary_analysis.timeframe if report.primary_analysis else ''})", f"🕐 {now}", "", - f"💰 Price: *{report.current_price:.3f}*", + f"💰 Price: *{report.current_price:.5f}*" if report.current_price < 10 else f"💰 Price: *{report.current_price:.3f}*", f"📊 Regime: {report.market_regime.replace('_', ' ').title()} | Vol: {report.volatility_regime}", "", "━━━━━━━━━━━━━━━━━━━━", @@ -119,15 +131,13 @@ def format_signal_message(report: SignalReport) -> str: if mtf: lines += ["", f"📈 *MTF*: {mtf}"] - # Reasons - bulls = _compact_reasons(report.bullish_reasons) - bears = _compact_reasons(report.bearish_reasons) - if bulls or bears: - lines.append("") - for r in bulls: - lines.append(f"✅ {r}") - for r in bears: - lines.append(f"❌ {r}") + # Indicator breakdown with arrows + if report.primary_analysis: + lines += ["", "━━━━━━━━━━━━━━━━━━━━", "📋 *Indicators*"] + for name, ind in report.primary_analysis.indicators.items(): + if name.startswith("_") or ind.weight == 0: + continue + lines.append(_indicator_line(name, ind)) # Warnings if report.warnings: @@ -161,29 +171,44 @@ def format_signal_message(report: SignalReport) -> str: # ── Keyboard helper ─────────────────────────────────────────────────── def scan_keyboard() -> InlineKeyboardMarkup: - return InlineKeyboardMarkup([[ - InlineKeyboardButton("🔍 Scan Signal", callback_data=SCAN_CB) - ]]) + return InlineKeyboardMarkup([ + [InlineKeyboardButton(f"🔍 {b['label']}", callback_data=b["cb"]) + for b in SCAN_BUTTONS[:2]], + [InlineKeyboardButton(f"🔍 {b['label']}", callback_data=b["cb"]) + for b in SCAN_BUTTONS[2:]], + ]) # ── Async signal runner (offloaded to thread pool) ──────────────────── -def _run_signal_sync() -> SignalReport: +TF_CONFIRMATIONS = { + Timeframe.M15: [Timeframe.M5, Timeframe.H1], + Timeframe.H1: [Timeframe.M15, Timeframe.H4], +} + + +def _run_signal_sync(symbol: str, timeframe: Timeframe) -> SignalReport: config = PlatformConfig() + config.symbol = symbol + config.primary_timeframe = timeframe + config.confirmation_timeframes = TF_CONFIRMATIONS.get( + timeframe, [Timeframe.M15, Timeframe.H4] + ) aggregator = SignalAggregator(config) - return aggregator.analyze_pair() + return aggregator.analyze_pair(symbol=symbol) -async def run_signal(loop: asyncio.AbstractEventLoop) -> SignalReport: - return await loop.run_in_executor(_executor, _run_signal_sync) +async def run_signal(symbol: str, timeframe: Timeframe) -> SignalReport: + loop = asyncio.get_event_loop() + return await loop.run_in_executor(_executor, _run_signal_sync, symbol, timeframe) # ── Handlers ────────────────────────────────────────────────────────── async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: text = ( - "👋 *ForexBot2 — USD/JPY Signal Intelligence*\n\n" - "Press the button below to fetch a live signal.\n" + "👋 *ForexBot2 — Signal Intelligence*\n\n" + "Pick a pair and timeframe to scan.\n" "Strong signals include a full trade setup with entry, SL and TP." ) await update.message.reply_text( @@ -197,15 +222,18 @@ async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: query = update.callback_query await query.answer() - # Replace button with spinner while scanning + btn = SCAN_LOOKUP.get(query.data) + if not btn: + return + + pair_display = btn["label"] await query.edit_message_text( - "⏳ Scanning USD/JPY…", + f"⏳ Scanning {pair_display}…", parse_mode=ParseMode.MARKDOWN, ) - loop = asyncio.get_event_loop() try: - report = await run_signal(loop) + report = await run_signal(btn["symbol"], btn["tf"]) text = format_signal_message(report) except Exception as exc: logger.exception("Signal scan failed") @@ -233,7 +261,7 @@ def main() -> None: ) app.add_handler(CommandHandler("start", cmd_start)) - app.add_handler(CallbackQueryHandler(cb_scan, pattern=f"^{SCAN_CB}$")) + app.add_handler(CallbackQueryHandler(cb_scan, pattern="^scan_")) logger.info("Bot running — press Ctrl+C to stop") app.run_polling(allowed_updates=Update.ALL_TYPES)