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).
This commit is contained in:
Harwinder
2026-05-10 23:49:36 -04:00
parent 7ccd8c4b04
commit a708609f8b
3 changed files with 87 additions and 50 deletions
+12 -4
View File
@@ -1,5 +1,5 @@
""" """
ForexBot2 — Configuration (USD/JPY focus) ForexBot2 — Configuration
========================================== ==========================================
""" """
@@ -35,10 +35,18 @@ class MarketSession(Enum):
OVERLAP_LONDON_NY = "london_ny_overlap" OVERLAP_LONDON_NY = "london_ny_overlap"
# ── Single Focus: USD/JPY ───────────────────────────────────────── # ── Supported pairs ──────────────────────────────────────────────
USDJPY = "USDJPY=X" 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" DISPLAY = "USD/JPY"
PIP_VALUE = 0.01 # JPY pairs use 0.01 per pip PIP_VALUE = 0.01
@dataclass @dataclass
@@ -109,7 +117,7 @@ class SignalConfig:
@dataclass @dataclass
class NewsConfig: class NewsConfig:
"""Economic calendar & sentiment.""" """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" min_impact: str = "medium"
pre_event_buffer_minutes: int = 30 pre_event_buffer_minutes: int = 30
post_event_buffer_minutes: int = 15 post_event_buffer_minutes: int = 15
+12 -11
View File
@@ -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. Fetches and validates OHLCV via the official `twelvedata` Python SDK.
""" """
@@ -13,7 +13,7 @@ from typing import Dict, Optional
import pandas as pd import pandas as pd
from twelvedata import TDClient 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__) logger = logging.getLogger(__name__)
@@ -37,6 +37,12 @@ class ForexDataProvider:
Timeframe.W1: "1week", 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_LIMIT = 8
RATE_WINDOW = 60.0 RATE_WINDOW = 60.0
@@ -103,23 +109,18 @@ class ForexDataProvider:
return None return None
def get_spread_estimate(self, symbol: str = USDJPY) -> float: def get_spread_estimate(self, symbol: str = USDJPY) -> float:
"""Estimate spread from recent M5 H-L median; fallback to 1 pip on JPY."""
try: try:
df = self.fetch_ohlcv(symbol, Timeframe.M5, lookback_days=1) df = self.fetch_ohlcv(symbol, Timeframe.M5, lookback_days=1)
if df is not None and not df.empty: if df is not None and not df.empty:
return float((df["High"] - df["Low"]).median()) return float((df["High"] - df["Low"]).median())
except Exception: except Exception:
pass pass
# Fallback: 1 pip = 0.01 for JPY pairs info = PAIR_INFO.get(symbol, PAIR_INFO[USDJPY])
return PIP_VALUE return info["pip_value"]
def get_pair_info(self, symbol: str = USDJPY) -> Dict: def get_pair_info(self, symbol: str = USDJPY) -> Dict:
return { info = PAIR_INFO.get(symbol, PAIR_INFO[USDJPY])
"symbol": symbol, return {"symbol": symbol, **info}
"display": DISPLAY,
"pip_value": PIP_VALUE,
"is_jpy_pair": True,
}
# ── Private ──────────────────────────────────────────────────── # ── Private ────────────────────────────────────────────────────
+63 -35
View File
@@ -28,7 +28,7 @@ from telegram.ext import (
ContextTypes, ContextTypes,
) )
from config import PlatformConfig, SignalStrength from config import PlatformConfig, SignalStrength, Timeframe, EURUSD, USDJPY, PAIR_INFO
from signal_aggregator import SignalAggregator, SignalReport from signal_aggregator import SignalAggregator, SignalReport
# ── Force UTF-8 on Windows ──────────────────────────────────────────── # ── Force UTF-8 on Windows ────────────────────────────────────────────
@@ -46,7 +46,14 @@ logger = logging.getLogger("telegram_bot")
TOKEN = os.environ.get("TELEGRAM_TOKEN", "8228251480:AAFW2OAyQ2idMicql2t_CIXk1r9-qfRTfNE") 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) _executor = ThreadPoolExecutor(max_workers=2)
@@ -80,14 +87,19 @@ def _mtf_line(report: SignalReport) -> str:
return " | ".join(parts) return " | ".join(parts)
def _compact_reasons(reasons: list[str], n: int = 3) -> list[str]: _ARROW = {
out = [] SignalStrength.STRONG_BUY: "⬆️",
for r in reasons[:n]: SignalStrength.BUY: "↗️",
s = r.replace("STRONG: ", "") SignalStrength.NEUTRAL: "➡️",
if " | " in s: SignalStrength.SELL: "↘️",
s = s.split(" | ", 1)[0] SignalStrength.STRONG_SELL: "⬇️",
out.append(s.strip()) }
return out
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: def format_signal_message(report: SignalReport) -> str:
@@ -102,10 +114,10 @@ def format_signal_message(report: SignalReport) -> str:
# Header # Header
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
lines += [ lines += [
f"🔍 *USD/JPY Signal Analysis*", f"🔍 *{report.display_name} Signal Analysis* ({report.primary_analysis.timeframe if report.primary_analysis else ''})",
f"🕐 {now}", 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}", f"📊 Regime: {report.market_regime.replace('_', ' ').title()} | Vol: {report.volatility_regime}",
"", "",
"━━━━━━━━━━━━━━━━━━━━", "━━━━━━━━━━━━━━━━━━━━",
@@ -119,15 +131,13 @@ def format_signal_message(report: SignalReport) -> str:
if mtf: if mtf:
lines += ["", f"📈 *MTF*: {mtf}"] lines += ["", f"📈 *MTF*: {mtf}"]
# Reasons # Indicator breakdown with arrows
bulls = _compact_reasons(report.bullish_reasons) if report.primary_analysis:
bears = _compact_reasons(report.bearish_reasons) lines += ["", "━━━━━━━━━━━━━━━━━━━━", "📋 *Indicators*"]
if bulls or bears: for name, ind in report.primary_analysis.indicators.items():
lines.append("") if name.startswith("_") or ind.weight == 0:
for r in bulls: continue
lines.append(f"✅ {r}") lines.append(_indicator_line(name, ind))
for r in bears:
lines.append(f"❌ {r}")
# Warnings # Warnings
if report.warnings: if report.warnings:
@@ -161,29 +171,44 @@ def format_signal_message(report: SignalReport) -> str:
# ── Keyboard helper ─────────────────────────────────────────────────── # ── Keyboard helper ───────────────────────────────────────────────────
def scan_keyboard() -> InlineKeyboardMarkup: def scan_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup([[ return InlineKeyboardMarkup([
InlineKeyboardButton("🔍 Scan Signal", callback_data=SCAN_CB) [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) ──────────────────── # ── 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 = PlatformConfig()
config.symbol = symbol
config.primary_timeframe = timeframe
config.confirmation_timeframes = TF_CONFIRMATIONS.get(
timeframe, [Timeframe.M15, Timeframe.H4]
)
aggregator = SignalAggregator(config) aggregator = SignalAggregator(config)
return aggregator.analyze_pair() return aggregator.analyze_pair(symbol=symbol)
async def run_signal(loop: asyncio.AbstractEventLoop) -> SignalReport: async def run_signal(symbol: str, timeframe: Timeframe) -> SignalReport:
return await loop.run_in_executor(_executor, _run_signal_sync) loop = asyncio.get_event_loop()
return await loop.run_in_executor(_executor, _run_signal_sync, symbol, timeframe)
# ── Handlers ────────────────────────────────────────────────────────── # ── Handlers ──────────────────────────────────────────────────────────
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
text = ( text = (
"👋 *ForexBot2 — USD/JPY Signal Intelligence*\n\n" "👋 *ForexBot2 — Signal Intelligence*\n\n"
"Press the button below to fetch a live signal.\n" "Pick a pair and timeframe to scan.\n"
"Strong signals include a full trade setup with entry, SL and TP." "Strong signals include a full trade setup with entry, SL and TP."
) )
await update.message.reply_text( await update.message.reply_text(
@@ -197,15 +222,18 @@ async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query query = update.callback_query
await query.answer() 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( await query.edit_message_text(
"⏳ Scanning USD/JPY…", f"⏳ Scanning {pair_display}…",
parse_mode=ParseMode.MARKDOWN, parse_mode=ParseMode.MARKDOWN,
) )
loop = asyncio.get_event_loop()
try: try:
report = await run_signal(loop) report = await run_signal(btn["symbol"], btn["tf"])
text = format_signal_message(report) text = format_signal_message(report)
except Exception as exc: except Exception as exc:
logger.exception("Signal scan failed") logger.exception("Signal scan failed")
@@ -233,7 +261,7 @@ def main() -> None:
) )
app.add_handler(CommandHandler("start", cmd_start)) 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") logger.info("Bot running — press Ctrl+C to stop")
app.run_polling(allowed_updates=Update.ALL_TYPES) app.run_polling(allowed_updates=Update.ALL_TYPES)