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:
@@ -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
@@ -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, [])
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -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]
|
||||||
|
row = [
|
||||||
InlineKeyboardButton(b["label"], callback_data=b["cb"])
|
InlineKeyboardButton(b["label"], callback_data=b["cb"])
|
||||||
for b in tf_buttons
|
for b in pair_btns
|
||||||
]]
|
]
|
||||||
if all_button:
|
rows.append(row)
|
||||||
rows.append([InlineKeyboardButton(all_button["label"], callback_data=all_button["cb"])])
|
|
||||||
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 | "
|
||||||
Reference in New Issue
Block a user