Add EUR/USD support with modular per-pair signal producer pattern

- Register EUR/USD in config (pip 0.0001, EurUsdStrategyConfig)
- Create pair_signals/ module: registry + dispatcher with per-pair
  config factories (xauusd.py, eurusd.py) for easy add/remove
- EUR/USD gets forex-tuned regime weights, London+NY session hours,
  XAU strategy gates disabled
- Add defense-in-depth display_name guards in signal_aggregator
- Rename telegram_bot.py → testing_telegram_bot.py with separate
  bot token, both pairs with dedicated button rows
- Add EUR/USD to news engine affected pairs map
- 9 new tests (146 total, all passing)
- Zero changes to engine.py, data_provider.py, main.py, backtest/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
harwinder
2026-05-18 12:18:55 -04:00
parent b61818330b
commit 3146a632d4
8 changed files with 314 additions and 51 deletions
+16 -1
View File
@@ -37,9 +37,11 @@ class MarketSession(Enum):
# ── Supported pairs ────────────────────────────────────────────── # ── Supported pairs ──────────────────────────────────────────────
XAUUSD = "XAU/USD" XAUUSD = "XAU/USD"
EURUSD = "EUR/USD"
PAIR_INFO = { PAIR_INFO = {
XAUUSD: {"display": "XAU/USD", "pip_value": 0.10, "is_jpy_pair": False}, XAUUSD: {"display": "XAU/USD", "pip_value": 0.10, "is_jpy_pair": False},
EURUSD: {"display": "EUR/USD", "pip_value": 0.0001, "is_jpy_pair": False},
} }
@@ -123,7 +125,7 @@ class SignalConfig:
@dataclass @dataclass
class NewsConfig: class NewsConfig:
"""Economic calendar & sentiment.""" """Economic calendar & sentiment."""
tracked_currencies: List[str] = field(default_factory=lambda: ["USD", "XAU"]) tracked_currencies: List[str] = field(default_factory=lambda: ["USD", "XAU", "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
@@ -159,6 +161,18 @@ class XauStrategyConfig:
sell_min_quality: List[str] = field(default_factory=lambda: ["A+", "A", "B"]) sell_min_quality: List[str] = field(default_factory=lambda: ["A+", "A", "B"])
@dataclass
class EurUsdStrategyConfig:
"""EUR/USD strategy config — separate from XAU for independent tuning."""
enabled: bool = True
blocked_hours_utc: List[int] = field(default_factory=lambda: [22, 23, 0, 1, 2, 3])
weekend_filter: bool = True
allowed_weekdays: List[int] = field(default_factory=lambda: [0, 1, 2, 3, 4])
min_atr_threshold: float = 0.0003
sl_atr_multiplier: float = 1.5
tp_atr_multiplier: float = 1.0
@dataclass @dataclass
class FilterConfig: class FilterConfig:
"""Regime and session filters for live signals.""" """Regime and session filters for live signals."""
@@ -182,6 +196,7 @@ class PlatformConfig:
news: NewsConfig = field(default_factory=NewsConfig) news: NewsConfig = field(default_factory=NewsConfig)
backtest: FilterConfig = field(default_factory=FilterConfig) backtest: FilterConfig = field(default_factory=FilterConfig)
xau_strategy: XauStrategyConfig = field(default_factory=XauStrategyConfig) xau_strategy: XauStrategyConfig = field(default_factory=XauStrategyConfig)
eur_strategy: EurUsdStrategyConfig = field(default_factory=EurUsdStrategyConfig)
symbol: str = XAUUSD symbol: str = XAUUSD
display_name: str = "XAU/USD" display_name: str = "XAU/USD"
+2 -1
View File
@@ -541,7 +541,8 @@ class NewsEngine:
@staticmethod @staticmethod
def _get_affected_pairs(currency: str) -> List[str]: def _get_affected_pairs(currency: str) -> List[str]:
_MAP = { _MAP = {
"USD": ["XAU/USD"], "USD": ["XAU/USD", "EUR/USD"],
"XAU": ["XAU/USD"], "XAU": ["XAU/USD"],
"EUR": ["EUR/USD"],
} }
return _MAP.get(currency, []) return _MAP.get(currency, [])
+52
View File
@@ -0,0 +1,52 @@
"""
Per-pair signal producers.
Each pair module exposes:
make_config(primary_tf, confirmation_tfs) -> PlatformConfig
To add a new pair: create pair_signals/<pair>.py with make_config(),
then register it in this file.
"""
from typing import Optional, List, Dict, Callable
from config import PlatformConfig, Timeframe
from signal_aggregator import SignalAggregator, SignalReport
from data_provider import ForexDataProvider
PAIR_PRODUCERS: Dict[str, Callable] = {}
def register_pair(symbol: str, config_factory: Callable):
PAIR_PRODUCERS[symbol] = config_factory
def get_supported_pairs() -> List[str]:
return list(PAIR_PRODUCERS.keys())
def produce_signal(
symbol: str,
primary_tf: Timeframe,
data_provider: ForexDataProvider,
confirmation_tfs: Optional[List[Timeframe]] = None,
) -> SignalReport:
config_factory = PAIR_PRODUCERS.get(symbol)
if config_factory is None:
raise ValueError(
f"No signal producer registered for {symbol}. "
f"Available: {list(PAIR_PRODUCERS.keys())}"
)
config = config_factory(primary_tf, confirmation_tfs)
aggregator = SignalAggregator(config)
aggregator.data_provider = data_provider
return aggregator.analyze_pair(symbol=symbol)
from pair_signals import xauusd as _xau_mod
from pair_signals import eurusd as _eur_mod
from config import XAUUSD, EURUSD
register_pair(XAUUSD, _xau_mod.make_config)
register_pair(EURUSD, _eur_mod.make_config)
+58
View File
@@ -0,0 +1,58 @@
"""EUR/USD signal producer config."""
from typing import Optional, List
from config import PlatformConfig, Timeframe, EURUSD
_CONFIRMATIONS = {
Timeframe.M5: [Timeframe.M15, Timeframe.H1],
Timeframe.M15: [Timeframe.M5, Timeframe.H1],
Timeframe.H1: [Timeframe.M15, Timeframe.H4],
Timeframe.H4: [Timeframe.H1, Timeframe.D1],
}
_TRENDING = {
"ema_trend": 2.0, "rsi": 1.0, "macd": 2.0,
"bollinger": 0.5, "keltner": 1.0, "roc": 1.5,
"candlestick": 0.5, "divergence": 2.0, "adx": 1.0,
}
EUR_REGIME_WEIGHTS = {
"trending_up": _TRENDING,
"trending_down": _TRENDING,
"trending": _TRENDING,
"ranging": {
"ema_trend": 0.3, "rsi": 2.0, "macd": 1.0,
"bollinger": 2.0, "keltner": 1.5, "roc": 0.5,
"candlestick": 0.8, "divergence": 2.5, "adx": 0.5,
},
"transitioning": {
"ema_trend": 1.5, "rsi": 1.5, "macd": 2.0,
"bollinger": 1.0, "keltner": 1.5, "roc": 2.0,
"candlestick": 0.8, "divergence": 2.0, "adx": 1.0,
},
}
def make_config(
primary_tf: Timeframe,
confirmation_tfs: Optional[List[Timeframe]] = None,
) -> PlatformConfig:
config = PlatformConfig()
config.symbol = EURUSD
config.display_name = "EUR/USD"
config.pip_value = 0.0001
config.primary_timeframe = primary_tf
config.confirmation_timeframes = (
confirmation_tfs or _CONFIRMATIONS.get(primary_tf, [Timeframe.M15, Timeframe.H4])
)
config.xau_strategy.enabled = False
config.backtest.session_start_hour = 7
config.backtest.session_end_hour = 20
config.backtest.blocked_hours_utc = [22, 23, 0, 1, 2, 3]
config.backtest.allowed_weekdays = [0, 1, 2, 3, 4]
config.technical.regime_weights_override = EUR_REGIME_WEIGHTS
return config
+23
View File
@@ -0,0 +1,23 @@
"""XAU/USD signal producer config."""
from typing import Optional, List
from config import PlatformConfig, Timeframe
_CONFIRMATIONS = {
Timeframe.M5: [Timeframe.M15, Timeframe.H1],
Timeframe.M15: [Timeframe.M5, Timeframe.H1],
Timeframe.H1: [Timeframe.M15, Timeframe.H4],
Timeframe.H4: [Timeframe.H1, Timeframe.D1],
}
def make_config(
primary_tf: Timeframe,
confirmation_tfs: Optional[List[Timeframe]] = None,
) -> PlatformConfig:
config = PlatformConfig()
config.primary_timeframe = primary_tf
config.confirmation_timeframes = (
confirmation_tfs or _CONFIRMATIONS.get(primary_tf, [Timeframe.M15, Timeframe.H4])
)
return config
+2 -1
View File
@@ -417,6 +417,7 @@ class SignalAggregator:
or (report.final_signal == SignalStrength.NEUTRAL and report.final_score < 0) or (report.final_signal == SignalStrength.NEUTRAL and report.final_score < 0)
) )
if (self.config.xau_strategy.enabled if (self.config.xau_strategy.enabled
and report.display_name == "XAU/USD"
and xau_is_sell_dir and xau_is_sell_dir
and report.signal_quality not in self.config.xau_strategy.sell_min_quality): and report.signal_quality not in self.config.xau_strategy.sell_min_quality):
report.warnings.append( report.warnings.append(
@@ -426,7 +427,7 @@ class SignalAggregator:
report.trade_setup = None report.trade_setup = None
# ── 7. Trade setup for actionable signals ────────────────── # ── 7. Trade setup for actionable signals ──────────────────
if report.trade_setup is None and report.signal_quality in ("A+", "A"): if report.trade_setup is None and report.signal_quality in ("A+", "A") and report.display_name == "XAU/USD":
atr = (report.primary_analysis.key_levels.get("atr", 0) atr = (report.primary_analysis.key_levels.get("atr", 0)
if report.primary_analysis else 0) if report.primary_analysis else 0)
if atr > 0: if atr > 0:
+115 -10
View File
@@ -88,8 +88,8 @@ print("\n═══ Module 1: config.py ═══")
from config import ( from config import (
Timeframe, SignalStrength, MarketSession, Timeframe, SignalStrength, MarketSession,
PlatformConfig, TechnicalConfig, SignalConfig, NewsConfig, PlatformConfig, TechnicalConfig, SignalConfig, NewsConfig,
FilterConfig, XauStrategyConfig, FilterConfig, XauStrategyConfig, EurUsdStrategyConfig,
XAUUSD, PAIR_INFO, XAUUSD, EURUSD, PAIR_INFO,
) )
@@ -1313,12 +1313,12 @@ def _():
# ══════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════
# MODULE 7: telegram_bot.py # MODULE 7: testing_telegram_bot.py
# ══════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 7: telegram_bot.py ═══") print("\n═══ Module 7: testing_telegram_bot.py ═══")
from telegram_bot import ( from testing_telegram_bot import (
_escape_md, _signal_label, _mtf_line, _escape_md, _signal_label, _mtf_line,
format_signal_message, _format_scan_summary, format_signal_message, _format_scan_summary,
_seconds_until_boundary, _check_level_breach, _seconds_until_boundary, _check_level_breach,
@@ -1491,7 +1491,7 @@ def _():
@test("SCAN_BUTTONS and SCAN_LOOKUP consistency") @test("SCAN_BUTTONS and SCAN_LOOKUP consistency")
def _(): def _():
assert len(SCAN_BUTTONS) == 5 # 4 TFs + "Scan All" assert len(SCAN_BUTTONS) == 10 # 5 per pair (4 TFs + All) × 2 pairs
for btn in SCAN_BUTTONS: for btn in SCAN_BUTTONS:
assert btn["cb"] in SCAN_LOOKUP assert btn["cb"] in SCAN_LOOKUP
assert "symbol" in btn assert "symbol" in btn
@@ -1531,12 +1531,12 @@ def _():
@test("_format_stats_dashboard returns waiting message when empty") @test("_format_stats_dashboard returns waiting message when empty")
def _(): def _():
import telegram_bot import testing_telegram_bot
old_stats = telegram_bot._signal_stats.copy() old_stats = testing_telegram_bot._signal_stats.copy()
telegram_bot._signal_stats.clear() testing_telegram_bot._signal_stats.clear()
text = _format_stats_dashboard() text = _format_stats_dashboard()
assert "Waiting" in text or "waiting" in text.lower() assert "Waiting" in text or "waiting" in text.lower()
telegram_bot._signal_stats.update(old_stats) testing_telegram_bot._signal_stats.update(old_stats)
# ══════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════
@@ -1653,6 +1653,111 @@ def _():
assert r1.market_regime == r2.market_regime assert r1.market_regime == r2.market_regime
# ══════════════════════════════════════════════════════════════════════
# MODULE 9: EUR/USD pair support
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 9: EUR/USD pair support ═══")
@test("PAIR_INFO includes EUR/USD with correct pip_value")
def _():
assert EURUSD in PAIR_INFO
assert PAIR_INFO[EURUSD]["pip_value"] == 0.0001
assert PAIR_INFO[EURUSD]["is_jpy_pair"] is False
assert PAIR_INFO[EURUSD]["display"] == "EUR/USD"
@test("EurUsdStrategyConfig defaults are sensible")
def _():
cfg = EurUsdStrategyConfig()
assert cfg.enabled is True
assert 0 in cfg.blocked_hours_utc
assert 5 not in cfg.allowed_weekdays # No Saturday
assert 6 not in cfg.allowed_weekdays # No Sunday
assert cfg.min_atr_threshold == 0.0003
@test("PlatformConfig has eur_strategy field")
def _():
config = PlatformConfig()
assert hasattr(config, "eur_strategy")
assert isinstance(config.eur_strategy, EurUsdStrategyConfig)
@test("EUR/USD config factory produces correct config")
def _():
from pair_signals.eurusd import make_config
config = make_config(Timeframe.H1)
assert config.symbol == EURUSD
assert config.display_name == "EUR/USD"
assert config.pip_value == 0.0001
assert config.xau_strategy.enabled is False
assert config.technical.regime_weights_override
assert "trending" in config.technical.regime_weights_override
assert "ranging" in config.technical.regime_weights_override
@test("XAU/USD config factory unchanged")
def _():
from pair_signals.xauusd import make_config
config = make_config(Timeframe.H1)
assert config.symbol == XAUUSD
assert config.display_name == "XAU/USD"
assert config.pip_value == 0.10
assert config.xau_strategy.enabled is True
@test("pair_signals registry includes both pairs")
def _():
from pair_signals import get_supported_pairs
pairs = get_supported_pairs()
assert XAUUSD in pairs
assert EURUSD in pairs
@test("EUR/USD signal analysis produces valid report (mocked data)")
def _():
from pair_signals.eurusd import make_config
from signal_aggregator import SignalAggregator
config = make_config(Timeframe.H1)
config.backtest.session_filter = False
agg = SignalAggregator(config)
df = make_ohlcv(n=300, start_price=1.0800, trend=0.00005)
dfs = {
Timeframe.H1: df,
Timeframe.M15: df,
Timeframe.H4: df,
}
report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False)
assert report.display_name == "EUR/USD"
assert report.signal_quality in ("A+", "A", "B", "C", "D", "F")
assert not any("SELL FILTER" in w for w in report.warnings)
@test("XAU sell gate does not apply to EUR/USD")
def _():
from pair_signals.eurusd import make_config
from signal_aggregator import SignalAggregator
config = make_config(Timeframe.H1)
config.backtest.session_filter = False
agg = SignalAggregator(config)
df = make_ohlcv(n=300, start_price=1.0800, trend=-0.0005, seed=99)
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False)
sell_filter_warnings = [w for w in report.warnings if "SELL FILTER" in w]
assert len(sell_filter_warnings) == 0
@test("NewsEngine._get_affected_pairs includes EUR/USD for EUR and USD events")
def _():
from news_engine import NewsEngine
assert "EUR/USD" in NewsEngine._get_affected_pairs("EUR")
assert "EUR/USD" in NewsEngine._get_affected_pairs("USD")
assert "XAU/USD" in NewsEngine._get_affected_pairs("USD")
assert "EUR/USD" not in NewsEngine._get_affected_pairs("GBP")
# ══════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════
# SUMMARY # SUMMARY
# ══════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════
+46 -38
View File
@@ -5,7 +5,7 @@ ForexBot2 — Telegram Bot
Async Telegram interface with candle-boundary auto-scanner and Async Telegram interface with candle-boundary auto-scanner and
real-time WebSocket level-breach monitor. real-time WebSocket level-breach monitor.
Manual scan: XAU/USD × 3 timeframes (M15/H1/H4) via inline buttons. Manual scan: XAU/USD + EUR/USD via inline buttons.
Auto-scan: Starts automatically on boot if TELEGRAM_CHAT_ID is set. Auto-scan: Starts automatically on boot if TELEGRAM_CHAT_ID is set.
/autoscan toggles for additional chats or to stop. /autoscan toggles for additional chats or to stop.
M5 fires at :00/:05/:10/… + 10s buffer (data only, no signals). M5 fires at :00/:05/:10/… + 10s buffer (data only, no signals).
@@ -19,8 +19,8 @@ WebSocket: Streams live ticks from TwelveData for XAU/USD.
5-minute cooldown prevents spam. 5-minute cooldown prevents spam.
Usage: Usage:
python telegram_bot.py python testing_telegram_bot.py
TELEGRAM_TOKEN=<token> TELEGRAM_CHAT_ID=<id> python telegram_bot.py TELEGRAM_TOKEN=<token> TELEGRAM_CHAT_ID=<id> python testing_telegram_bot.py
""" """
import asyncio import asyncio
@@ -48,10 +48,11 @@ from telegram.ext import (
from config import ( from config import (
PlatformConfig, SignalStrength, Timeframe, PlatformConfig, SignalStrength, Timeframe,
XAUUSD, PAIR_INFO, XAUUSD, EURUSD, PAIR_INFO,
) )
from data_provider import ForexDataProvider from data_provider import ForexDataProvider
from signal_aggregator import SignalAggregator, SignalReport from signal_aggregator import SignalAggregator, SignalReport
from pair_signals import produce_signal
# ── Force UTF-8 on Windows ──────────────────────────────────────────── # ── Force UTF-8 on Windows ────────────────────────────────────────────
if hasattr(sys.stdout, "reconfigure"): if hasattr(sys.stdout, "reconfigure"):
@@ -66,12 +67,12 @@ logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING) logging.getLogger("httpcore").setLevel(logging.WARNING)
logger = logging.getLogger("telegram_bot") logger = logging.getLogger("telegram_bot")
TOKEN = os.environ.get("TELEGRAM_TOKEN", "8228251480:AAFW2OAyQ2idMicql2t_CIXk1r9-qfRTfNE") TOKEN = os.environ.get("TELEGRAM_TOKEN", "8882954582:AAEOi2MzoMbJjozDMiRtFBHc2muix_MtaQQ")
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6025899022") CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6025899022")
# ── Pairs & buttons ─────────────────────────────────────────────────── # ── Pairs & buttons ───────────────────────────────────────────────────
SCAN_PAIRS = [XAUUSD] SCAN_PAIRS = [XAUUSD, EURUSD]
_WS_TO_PAIR = { _WS_TO_PAIR = {
"XAU/USD": XAUUSD, "XAU/USD": XAUUSD,
@@ -91,11 +92,16 @@ _signal_stats: Dict[str, Dict[str, SignalReport]] = {}
_stats_updated: Optional[float] = None _stats_updated: Optional[float] = None
SCAN_BUTTONS = [ SCAN_BUTTONS = [
{"cb": "scan_xauusd_m5", "symbol": XAUUSD, "tf": Timeframe.M5, "label": "5 Min"}, {"cb": "scan_xauusd_m5", "symbol": XAUUSD, "tf": Timeframe.M5, "label": "XAU 5m"},
{"cb": "scan_xauusd_m15", "symbol": XAUUSD, "tf": Timeframe.M15, "label": "15 Min"}, {"cb": "scan_xauusd_m15", "symbol": XAUUSD, "tf": Timeframe.M15, "label": "XAU 15m"},
{"cb": "scan_xauusd_h1", "symbol": XAUUSD, "tf": Timeframe.H1, "label": "1 Hour"}, {"cb": "scan_xauusd_h1", "symbol": XAUUSD, "tf": Timeframe.H1, "label": "XAU 1h"},
{"cb": "scan_xauusd_h4", "symbol": XAUUSD, "tf": Timeframe.H4, "label": "4 Hour"}, {"cb": "scan_xauusd_h4", "symbol": XAUUSD, "tf": Timeframe.H4, "label": "XAU 4h"},
{"cb": "scan_xauusd_all", "symbol": XAUUSD, "tf": None, "label": "Scan All TFs"}, {"cb": "scan_xauusd_all", "symbol": XAUUSD, "tf": None, "label": "XAU All"},
{"cb": "scan_eurusd_m5", "symbol": EURUSD, "tf": Timeframe.M5, "label": "EUR 5m"},
{"cb": "scan_eurusd_m15", "symbol": EURUSD, "tf": Timeframe.M15, "label": "EUR 15m"},
{"cb": "scan_eurusd_h1", "symbol": EURUSD, "tf": Timeframe.H1, "label": "EUR 1h"},
{"cb": "scan_eurusd_h4", "symbol": EURUSD, "tf": Timeframe.H4, "label": "EUR 4h"},
{"cb": "scan_eurusd_all", "symbol": EURUSD, "tf": None, "label": "EUR All"},
] ]
SCAN_LOOKUP = {b["cb"]: b for b in SCAN_BUTTONS} SCAN_LOOKUP = {b["cb"]: b for b in SCAN_BUTTONS}
@@ -408,14 +414,14 @@ async def _dashboard_loop(bot) -> None:
# ── Keyboard helper ─────────────────────────────────────────────────── # ── Keyboard helper ───────────────────────────────────────────────────
def scan_keyboard() -> InlineKeyboardMarkup: def scan_keyboard() -> InlineKeyboardMarkup:
tf_buttons = [b for b in SCAN_BUTTONS if b["tf"] is not None] rows = []
all_button = next((b for b in SCAN_BUTTONS if b["tf"] is None), None) for pair in SCAN_PAIRS:
rows = [[ pair_btns = [b for b in SCAN_BUTTONS if b["symbol"] == pair]
InlineKeyboardButton(b["label"], callback_data=b["cb"]) row = [
for b in tf_buttons InlineKeyboardButton(b["label"], callback_data=b["cb"])
]] for b in pair_btns
if all_button: ]
rows.append([InlineKeyboardButton(all_button["label"], callback_data=all_button["cb"])]) rows.append(row)
return InlineKeyboardMarkup(rows) return InlineKeyboardMarkup(rows)
@@ -430,12 +436,12 @@ def _run_signal_sync(
confirmation_tfs = TF_CONFIRMATIONS.get( confirmation_tfs = TF_CONFIRMATIONS.get(
timeframe, [Timeframe.M15, Timeframe.H4] timeframe, [Timeframe.M15, Timeframe.H4]
) )
config = PlatformConfig() return produce_signal(
config.primary_timeframe = timeframe symbol=symbol,
config.confirmation_timeframes = confirmation_tfs primary_tf=timeframe,
aggregator = SignalAggregator(config) data_provider=_get_shared_dp(),
aggregator.data_provider = _get_shared_dp() confirmation_tfs=confirmation_tfs,
return aggregator.analyze_pair(symbol=symbol) )
async def run_signal( async def run_signal(
@@ -709,8 +715,8 @@ def _stop_scheduler() -> None:
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
scanning = "running" if _autoscan_chats else "off" scanning = "running" if _autoscan_chats else "off"
text = ( text = (
"👋 *ForexBot2 — XAU/USD Signal Intelligence*\n\n" "👋 *ForexBot2 — Signal Intelligence*\n\n"
"Pick a timeframe to scan gold.\n" "Pick a pair and timeframe to scan.\n"
"Signals include a full trade setup with entry, SL and TP.\n\n" "Signals include a full trade setup with entry, SL and TP.\n\n"
f"Auto-scan: *{scanning}* — /autoscan to toggle\n" f"Auto-scan: *{scanning}* — /autoscan to toggle\n"
"/scan — Quick scan buttons\n" "/scan — Quick scan buttons\n"
@@ -728,7 +734,7 @@ async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
text = ( text = (
"📖 *ForexBot2 Commands*\n\n" "📖 *ForexBot2 Commands*\n\n"
"/start — Welcome & scan buttons\n" "/start — Welcome & scan buttons\n"
"/scan — Quick XAU/USD scan buttons\n" "/scan — Quick scan buttons (XAU/USD + EUR/USD)\n"
"/autoscan — Toggle auto-scan on/off\n" "/autoscan — Toggle auto-scan on/off\n"
"/status — Bot status, WS connection, live prices\n" "/status — Bot status, WS connection, live prices\n"
"/cache — Show data cache age per pair/TF\n" "/cache — Show data cache age per pair/TF\n"
@@ -851,7 +857,7 @@ async def cmd_autoscan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
next_h4 = _seconds_until_boundary(240, buffer=10) next_h4 = _seconds_until_boundary(240, buffer=10)
await update.message.reply_text( await update.message.reply_text(
"✅ *Auto-scan enabled*\n\n" "✅ *Auto-scan enabled*\n\n"
f"Scanning XAU/USD on M5/M15/H1/H4 at candle close.\n" f"Scanning XAU/USD + EUR/USD on M5/M15/H1/H4 at candle close.\n"
f"📊 Live dashboard refreshes every {_DASHBOARD_REFRESH_INTERVAL}s.\n" f"📊 Live dashboard refreshes every {_DASHBOARD_REFRESH_INTERVAL}s.\n"
f"📡 Live price monitoring active.\n" f"📡 Live price monitoring active.\n"
f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | " f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | "
@@ -863,7 +869,7 @@ async def cmd_autoscan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
async def cmd_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text( await update.message.reply_text(
"🔍 *XAU/USD — Pick a timeframe*", "🔍 *Pick a pair & timeframe*",
parse_mode=ParseMode.MARKDOWN, parse_mode=ParseMode.MARKDOWN,
reply_markup=scan_keyboard(), reply_markup=scan_keyboard(),
) )
@@ -878,11 +884,12 @@ async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
return return
if btn["tf"] is None: if btn["tf"] is None:
await _cb_scan_all(query) await _cb_scan_all(query, symbol=btn["symbol"])
return return
display = PAIR_INFO.get(btn["symbol"], {}).get("display", btn["symbol"])
await query.edit_message_text( await query.edit_message_text(
f"⏳ Scanning XAU/USD {btn['label']}…", f"⏳ Scanning {display} {btn['label']}…",
parse_mode=ParseMode.MARKDOWN, parse_mode=ParseMode.MARKDOWN,
) )
@@ -900,14 +907,15 @@ async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
) )
async def _cb_scan_all(query) -> None: async def _cb_scan_all(query, symbol: str = XAUUSD) -> None:
display = PAIR_INFO.get(symbol, {}).get("display", symbol)
await query.edit_message_text( await query.edit_message_text(
"⏳ Scanning XAU/USD across M5 / M15 / H1 / H4…", f"⏳ Scanning {display} across M5 / M15 / H1 / H4…",
parse_mode=ParseMode.MARKDOWN, parse_mode=ParseMode.MARKDOWN,
) )
tfs = [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.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) results = await asyncio.gather(*tasks, return_exceptions=True)
reports = [] reports = []
@@ -923,7 +931,7 @@ async def _cb_scan_all(query) -> None:
_update_level_cache(reports) _update_level_cache(reports)
lines = [ lines = [
"📊 *XAU/USD — All Timeframes*", f"📊 *{display} — All Timeframes*",
f"🕐 {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}", f"🕐 {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
"", "",
] ]
@@ -967,7 +975,7 @@ async def _post_init(app: Application) -> None:
"""Register command menu and start scheduler on boot.""" """Register command menu and start scheduler on boot."""
await app.bot.set_my_commands([ await app.bot.set_my_commands([
("start", "Welcome & scan buttons"), ("start", "Welcome & scan buttons"),
("scan", "Quick XAU/USD scan buttons"), ("scan", "Quick scan buttons (XAU/USD + EUR/USD)"),
("autoscan", "Toggle auto-scan on/off"), ("autoscan", "Toggle auto-scan on/off"),
("status", "Bot status, WS connection, live prices"), ("status", "Bot status, WS connection, live prices"),
("cache", "Show data cache age per pair/TF"), ("cache", "Show data cache age per pair/TF"),
@@ -1016,7 +1024,7 @@ async def _post_init(app: Application) -> None:
await app.bot.send_message( await app.bot.send_message(
chat_id, chat_id,
f"🤖 *ForexBot2 started*\n\n" f"🤖 *ForexBot2 started*\n\n"
f"Auto-scanning XAU/USD on M5/M15/H1/H4.\n" f"Auto-scanning XAU/USD + EUR/USD on M5/M15/H1/H4.\n"
f"📊 Live dashboard refreshes every {_DASHBOARD_REFRESH_INTERVAL}s.\n" f"📊 Live dashboard refreshes every {_DASHBOARD_REFRESH_INTERVAL}s.\n"
f"📡 Live price monitoring active.\n" f"📡 Live price monitoring active.\n"
f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | " f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | "