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). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user