Refocus on XAU/USD: add momentum indicators, tune TP, exhaustion block

Strip EUR/USD, GBP/USD, USD/JPY pairs and strategies from entire codebase.
Add 3 new scored indicators (ROC, Keltner/TTM Squeeze, ADX slope) and
enable MACD-as-pillar + divergence detection for early trend entry.
Introduce "transitioning" regime (ADX < 25, slope > 3.0) to catch trend
births before ADX confirms. Gold-specific composite weights per regime.

Tune TP multipliers: quality-tiered scaling (A+/A get 1.5-2.0x ATR via
XAU ATR method, B/C use swing-level targeting). Backtest: +24R total,
65% WR, PF 1.12 over 365 days.

Add momentum-confirmed exhaustion block: hard-reject trades when 2+
static exhaustion signals fire AND ROC/MACD confirm fading momentum.
Prevents late trend entries without killing strong trend trades.

Telegram: simplify buttons to timeframe-only labels, add "Scan All TFs"
multi-timeframe summary, add /scan command, fix BB/KC level breach keys,
fix price formatting (.2f for gold), fix deprecated asyncio call, fix
"1 pairs" grammar and 60s/30s dashboard refresh mismatch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
harwinder
2026-05-15 10:51:51 -04:00
parent 98daf71a52
commit 8ed6454ea5
15 changed files with 852 additions and 2108 deletions
+122 -93
View File
@@ -5,26 +5,18 @@ ForexBot2 — Telegram Bot
Async Telegram interface with candle-boundary auto-scanner and
real-time WebSocket level-breach monitor.
Manual scan: 4 pairs × 3 timeframes (M15/H1/H4) via inline buttons.
Manual scan: XAU/USD × 3 timeframes (M15/H1/H4) via inline buttons.
Auto-scan: Starts automatically on boot if TELEGRAM_CHAT_ID is set.
/autoscan toggles for additional chats or to stop.
M5 fires at :00/:05/:10/… + 10s buffer (data only, no signals).
M15 fires at :00/:15/:30/:45 + 10s buffer.
H1 fires at :00 + 10s buffer.
H4 fires at :00 every 4 hours + 10s buffer.
WebSocket: Streams live ticks from TwelveData for EUR/USD & XAU/USD
(Grow plan supports only these 2 WS symbols).
WebSocket: Streams live ticks from TwelveData for XAU/USD.
When price crosses a key structure level (swing S/R, BB,
EMA200) cached from the last boundary scan, triggers an
immediate M15 scan and sends a Level Break Alert.
5-minute cooldown per pair prevents spam.
GBP/USD & USD/JPY are monitored via REST scans only.
Plan: TwelveData Grow (55 credits/min, 8 trial WS credits).
Credit budget (worst case, 4-hour boundary):
Unique fetches: 4 pairs × ~6 TFs = ~24 calls (rest cached) → fits in 55/min.
WebSocket: 2 symbols = 2 credits (one-time on connect).
Level breach scans: ~3 REST calls each, ~10/hour worst case.
5-minute cooldown prevents spam.
Usage:
python telegram_bot.py
@@ -55,7 +47,7 @@ from telegram.ext import (
from config import (
PlatformConfig, SignalStrength, Timeframe,
EURUSD, GBPUSD, XAUUSD, PAIR_INFO,
XAUUSD, PAIR_INFO,
)
from data_provider import ForexDataProvider
from signal_aggregator import SignalAggregator, SignalReport
@@ -78,14 +70,9 @@ CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6025899022")
# ── Pairs & buttons ───────────────────────────────────────────────────
SCAN_PAIRS = [EURUSD, GBPUSD, XAUUSD]
CORRELATION_GROUPS = {
"usd_negative": {"pairs": {EURUSD, GBPUSD}, "label": "USD-weakness"},
}
SCAN_PAIRS = [XAUUSD]
_WS_TO_PAIR = {
"EUR/USD": EURUSD,
"XAU/USD": XAUUSD,
}
_WS_SYMBOLS = list(_WS_TO_PAIR.keys())
@@ -93,7 +80,8 @@ _WS_SYMBOLS = list(_WS_TO_PAIR.keys())
_LEVEL_KEYS = (
"swing_high_1", "swing_high_2", "swing_high_3",
"swing_low_1", "swing_low_2", "swing_low_3",
"ema_trend",
"ema_trend", "bb_upper", "bb_lower",
"kc_upper", "kc_lower",
)
# ── Live signal stats cache ──────────────────────────────────────────
@@ -102,15 +90,10 @@ _signal_stats: Dict[str, Dict[str, SignalReport]] = {}
_stats_updated: Optional[float] = None
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_eurusd_h4", "symbol": EURUSD, "tf": Timeframe.H4, "label": "EUR/USD 4H"},
{"cb": "scan_gbpusd_m15", "symbol": GBPUSD, "tf": Timeframe.M15, "label": "GBP/USD 15M"},
{"cb": "scan_gbpusd_h1", "symbol": GBPUSD, "tf": Timeframe.H1, "label": "GBP/USD 1H"},
{"cb": "scan_gbpusd_h4", "symbol": GBPUSD, "tf": Timeframe.H4, "label": "GBP/USD 4H"},
{"cb": "scan_xauusd_m15", "symbol": XAUUSD, "tf": Timeframe.M15, "label": "XAU/USD 15M"},
{"cb": "scan_xauusd_h1", "symbol": XAUUSD, "tf": Timeframe.H1, "label": "XAU/USD 1H"},
{"cb": "scan_xauusd_h4", "symbol": XAUUSD, "tf": Timeframe.H4, "label": "XAU/USD 4H"},
{"cb": "scan_xauusd_m15", "symbol": XAUUSD, "tf": Timeframe.M15, "label": "15 Min"},
{"cb": "scan_xauusd_h1", "symbol": XAUUSD, "tf": Timeframe.H1, "label": "1 Hour"},
{"cb": "scan_xauusd_h4", "symbol": XAUUSD, "tf": Timeframe.H4, "label": "4 Hour"},
{"cb": "scan_xauusd_all", "symbol": XAUUSD, "tf": None, "label": "Scan All TFs"},
]
SCAN_LOOKUP = {b["cb"]: b for b in SCAN_BUTTONS}
@@ -139,6 +122,10 @@ def _escape_md(text: str) -> str:
return text
def _fmt_price(price: float) -> str:
return f"{price:.2f}" if price >= 10 else f"{price:.5f}"
# ── Shared data provider (single cache across all scans) ─────────────
_shared_dp: Optional[ForexDataProvider] = None
@@ -210,7 +197,7 @@ def format_signal_message(report: SignalReport) -> str:
f"🔍 *{report.display_name} Signal Analysis* ({report.primary_analysis.timeframe if report.primary_analysis else ''})",
f"🕐 {now}",
"",
f"💰 Price: *{report.current_price:.5f}*" if report.current_price < 10 else f"💰 Price: *{report.current_price:.3f}*",
f"💰 Price: *{_fmt_price(report.current_price)}*",
f"📊 Regime: {regime_str} | Vol: {vol_str}",
"",
"━━━━━━━━━━━━━━━━━━━━",
@@ -243,12 +230,12 @@ def format_signal_message(report: SignalReport) -> str:
"━━━━━━━━━━━━━━━━━━━━",
f"🚀 *TRADE SETUP*",
f"{dir_emoji} Direction: *{ts.direction}*",
f"▶ Entry: `{ts.entry_price:.3f}`",
f"🛑 SL: `{ts.stop_loss:.3f}` ({ts.risk_pips:.1f} pips)",
f"🎯 TP1: `{ts.take_profit_1:.3f}` ({ts.reward_pips:.1f} pips)",
f"▶ Entry: `{_fmt_price(ts.entry_price)}`",
f"🛑 SL: `{_fmt_price(ts.stop_loss)}` ({ts.risk_pips:.1f} pips)",
f"🎯 TP1: `{_fmt_price(ts.take_profit_1)}` ({ts.reward_pips:.1f} pips)",
]
if ts.take_profit_2:
lines.append(f"🎯 TP2: `{ts.take_profit_2:.3f}`")
lines.append(f"🎯 TP2: `{_fmt_price(ts.take_profit_2)}`")
lines.append(f"⚖️ RR: 1:{ts.risk_reward:.1f}")
lines += ["", "━━━━━━━━━━━━━━━━━━━━",
@@ -257,33 +244,6 @@ def format_signal_message(report: SignalReport) -> str:
return "\n".join(lines)
# ── Correlation filter ───────────────────────────────────────────────
def _detect_correlated_exposure(reports: List[SignalReport]) -> List[str]:
warnings = []
for group_info in CORRELATION_GROUPS.values():
group_pairs = group_info["pairs"]
group_label = group_info["label"]
active = [
r for r in reports
if r.symbol in group_pairs
and r.final_signal not in (SignalStrength.NEUTRAL,)
]
if len(active) >= 2:
directions = set()
for r in active:
if r.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY):
directions.add("BUY")
else:
directions.add("SELL")
if len(directions) == 1:
names = ", ".join(r.display_name for r in active)
warnings.append(
f"⚠️ Same exposure ({group_label}): {names} both {directions.pop()}"
)
return warnings
# ── Auto-scan summary format ─────────────────────────────────────────
def _format_scan_summary(reports: List[SignalReport], tf: Timeframe) -> str:
@@ -310,10 +270,6 @@ def _format_scan_summary(reports: List[SignalReport], tf: Timeframe) -> str:
else:
lines.append("No actionable signals this cycle.")
corr_warnings = _detect_correlated_exposure(reports)
for w in corr_warnings:
lines.append(w)
return "\n".join(lines)
@@ -358,10 +314,7 @@ def _format_stats_dashboard() -> str:
break
price = ws_price or (latest.current_price if latest else 0)
if price < 10:
price_str = f"`{price:.5f}`"
else:
price_str = f"`{price:.2f}`"
price_str = f"`{_fmt_price(price)}`"
regime = latest.market_regime.replace("_", " ").title() if latest else ""
@@ -383,8 +336,8 @@ def _format_stats_dashboard() -> str:
ts = latest.trade_setup
d = "📈" if ts.direction == "BUY" else "📉"
lines.append(
f"{d} {ts.direction} `{ts.entry_price:.3f}` "
f"SL `{ts.stop_loss:.3f}` TP `{ts.take_profit_1:.3f}` "
f"{d} {ts.direction} `{_fmt_price(ts.entry_price)}` "
f"SL `{_fmt_price(ts.stop_loss)}` TP `{_fmt_price(ts.take_profit_1)}` "
f"RR 1:{ts.risk_reward:.1f}"
)
@@ -452,12 +405,14 @@ async def _dashboard_loop(bot) -> None:
# ── Keyboard helper ───────────────────────────────────────────────────
def scan_keyboard() -> InlineKeyboardMarkup:
rows = []
for i in range(0, len(SCAN_BUTTONS), 4):
rows.append([
InlineKeyboardButton(b["label"], callback_data=b["cb"])
for b in SCAN_BUTTONS[i:i + 4]
])
tf_buttons = [b for b in SCAN_BUTTONS if b["tf"] is not None]
all_button = next((b for b in SCAN_BUTTONS if b["tf"] is None), None)
rows = [[
InlineKeyboardButton(b["label"], callback_data=b["cb"])
for b in tf_buttons
]]
if all_button:
rows.append([InlineKeyboardButton(all_button["label"], callback_data=all_button["cb"])])
return InlineKeyboardMarkup(rows)
@@ -485,7 +440,7 @@ async def run_signal(
timeframe: Timeframe,
confirmation_tfs: Optional[List[Timeframe]] = None,
) -> SignalReport:
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
_executor, _run_signal_sync, symbol, timeframe, confirmation_tfs
)
@@ -729,7 +684,7 @@ def _start_scheduler(bot) -> None:
if _dashboard_task is None or _dashboard_task.done():
_dashboard_task = asyncio.create_task(_dashboard_loop(bot))
logger.info("Dashboard 60s refresh loop started")
logger.info(f"Dashboard {_DASHBOARD_REFRESH_INTERVAL}s refresh loop started")
def _stop_scheduler() -> None:
@@ -755,10 +710,11 @@ def _stop_scheduler() -> None:
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
scanning = "running" if _autoscan_chats else "off"
text = (
"👋 *ForexBot2 — Signal Intelligence*\n\n"
"Pick a pair and timeframe to scan.\n"
"👋 *ForexBot2 — XAU/USD Signal Intelligence*\n\n"
"Pick a timeframe to scan gold.\n"
"Signals include a full trade setup with entry, SL and TP.\n\n"
f"Auto-scan: *{scanning}* — /autoscan to toggle\n"
"/scan — Quick scan buttons\n"
"/status — Bot & WebSocket status\n"
"/help — All commands"
)
@@ -772,15 +728,16 @@ async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
text = (
"📖 *ForexBot2 Commands*\n\n"
"/start — Show scan buttons\n"
"/start — Welcome & scan buttons\n"
"/scan — Quick XAU/USD scan buttons\n"
"/autoscan — Toggle auto-scan on/off\n"
"/status — Bot status, WS connection, live prices\n"
"/cache — Show data cache age per pair/TF\n"
"/clearcache — Wipe data cache, force fresh fetch\n"
"/help — This message\n\n"
"*Auto-scan* fires at candle close: M5/M15/H1/H4.\n"
"Live dashboard auto-refreshes every 60s.\n"
"*WebSocket* monitors live prices and updates level cache."
f"Live dashboard auto-refreshes every {_DASHBOARD_REFRESH_INTERVAL}s.\n"
"*WebSocket* monitors live prices and triggers breach scans."
)
await update.message.reply_text(text, parse_mode=ParseMode.MARKDOWN)
@@ -808,10 +765,8 @@ async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None
age = time.time() - _last_price_ts.get(pair, 0)
if price is None:
lines.append(f" {display}: waiting...")
elif price < 10:
lines.append(f" {display}: `{price:.5f}` ({age:.0f}s ago)")
else:
lines.append(f" {display}: `{price:.3f}` ({age:.0f}s ago)")
lines.append(f" {display}: `{_fmt_price(price)}` ({age:.0f}s ago)")
else:
lines.append("\n_No live prices yet (WS not streaming)_")
@@ -897,8 +852,8 @@ async def cmd_autoscan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
next_h4 = _seconds_until_boundary(240, buffer=10)
await update.message.reply_text(
"✅ *Auto-scan enabled*\n\n"
f"Scanning {len(SCAN_PAIRS)} pairs on M5/M15/H1/H4 at candle close.\n"
f"📊 Live dashboard refreshes every 60s.\n"
f"Scanning XAU/USD on M5/M15/H1/H4 at candle close.\n"
f"📊 Live dashboard refreshes every {_DASHBOARD_REFRESH_INTERVAL}s.\n"
f"📡 Live price monitoring active.\n"
f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | "
f"H1 {next_h1 / 60:.0f}m | H4 {next_h4 / 60:.0f}m\n\n"
@@ -907,6 +862,14 @@ async def cmd_autoscan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
)
async def cmd_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(
"🔍 *XAU/USD — Pick a timeframe*",
parse_mode=ParseMode.MARKDOWN,
reply_markup=scan_keyboard(),
)
async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
await query.answer()
@@ -915,9 +878,12 @@ async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not btn:
return
pair_display = btn["label"]
if btn["tf"] is None:
await _cb_scan_all(query)
return
await query.edit_message_text(
f"⏳ Scanning {pair_display}…",
f"⏳ Scanning XAU/USD {btn['label']}…",
parse_mode=ParseMode.MARKDOWN,
)
@@ -935,12 +901,74 @@ async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
)
async def _cb_scan_all(query) -> None:
await query.edit_message_text(
"⏳ Scanning XAU/USD across M15 / H1 / H4…",
parse_mode=ParseMode.MARKDOWN,
)
tfs = [Timeframe.M15, Timeframe.H1, Timeframe.H4]
tasks = [run_signal(XAUUSD, tf) for tf in tfs]
results = await asyncio.gather(*tasks, return_exceptions=True)
reports = []
errors = []
for tf, result in zip(tfs, results):
if isinstance(result, Exception):
errors.append(f"❌ {tf.value}: {result}")
else:
reports.append(result)
_update_signal_stats([result], tf)
if reports:
_update_level_cache(reports)
lines = [
"📊 *XAU/USD — All Timeframes*",
f"🕐 {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
"",
]
for r in reports:
emoji = _SIGNAL_EMOJI.get(r.final_signal, "⬜")
qe = _QUALITY_EMOJI.get(r.signal_quality, "")
tf_label = r.primary_analysis.timeframe if r.primary_analysis else "?"
sig = _signal_label(r.final_signal)
regime = r.market_regime.replace("_", " ").title()
lines.append(
f"{emoji} *{tf_label}* {sig} {qe}{r.signal_quality} "
f"Score: {r.final_score:+.1f} {r.final_confidence:.0f}%"
)
lines.append(f" _{_escape_md(regime)}_ | {'✅ MTF' if r.mtf_agreement else '❌ MTF'}")
if r.trade_setup and r.final_signal != SignalStrength.NEUTRAL:
ts = r.trade_setup
d = "📈" if ts.direction == "BUY" else "📉"
lines.append(
f" {d} {ts.direction} `{_fmt_price(ts.entry_price)}` "
f"SL `{_fmt_price(ts.stop_loss)}` TP `{_fmt_price(ts.take_profit_1)}` "
f"RR 1:{ts.risk_reward:.1f}"
)
lines.append("")
for err in errors:
lines.append(err)
lines.append("_Use buttons below for detailed single-TF view._")
await query.edit_message_text(
"\n".join(lines),
parse_mode=ParseMode.MARKDOWN,
reply_markup=scan_keyboard(),
)
# ── Boot-time auto-start ──────────────────────────────────────────────
async def _post_init(app: Application) -> None:
"""Register command menu and start scheduler on boot."""
await app.bot.set_my_commands([
("start", "Show scan buttons"),
("start", "Welcome & scan buttons"),
("scan", "Quick XAU/USD scan buttons"),
("autoscan", "Toggle auto-scan on/off"),
("status", "Bot status, WS connection, live prices"),
("cache", "Show data cache age per pair/TF"),
@@ -989,8 +1017,8 @@ async def _post_init(app: Application) -> None:
await app.bot.send_message(
chat_id,
f"🤖 *ForexBot2 started*\n\n"
f"Auto-scanning {len(SCAN_PAIRS)} pairs on M5/M15/H1/H4.\n"
f"📊 Live dashboard refreshes every 60s.\n"
f"Auto-scanning XAU/USD on M5/M15/H1/H4.\n"
f"📊 Live dashboard refreshes every {_DASHBOARD_REFRESH_INTERVAL}s.\n"
f"📡 Live price monitoring active.\n"
f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | "
f"H1 {next_h1 / 60:.0f}m | H4 {next_h4 / 60:.0f}m\n\n"
@@ -1028,6 +1056,7 @@ def main() -> None:
)
app.add_handler(CommandHandler("start", cmd_start))
app.add_handler(CommandHandler("scan", cmd_scan))
app.add_handler(CommandHandler("help", cmd_help))
app.add_handler(CommandHandler("status", cmd_status))
app.add_handler(CommandHandler("autoscan", cmd_autoscan))