diff --git a/engine.py b/engine.py
index 046374e..7fb242e 100644
--- a/engine.py
+++ b/engine.py
@@ -499,7 +499,8 @@ class TechnicalEngine:
# Compare to historical ATR
if len(atr) >= 50:
- atr_percentile = (atr.iloc[-1] > atr.iloc[-50:]).mean() * 100
+ # Exclude current bar so ATR isn't compared against itself
+ atr_percentile = (atr.iloc[-1] > atr.iloc[-50:-1]).mean() * 100
else:
atr_percentile = 50.0
@@ -844,7 +845,8 @@ class TechnicalEngine:
- Confidence scaling
"""
total_score = 0.0
- total_weight = 0.0
+ score_weight = 0.0 # denominator for score: weight × confidence
+ weight_sum = 0.0 # denominator for confidence average: weight only
total_confidence = 0.0
regime_weights = {
@@ -863,16 +865,18 @@ class TechnicalEngine:
regime_mult = active_regime_weights.get(name, 1.0)
weight *= regime_mult
+ conf_factor = ind.confidence / 100.0
signal_score = ind.signal.value # -2 to +2
- weighted_score = signal_score * weight * (ind.confidence / 100.0)
- total_score += weighted_score
- total_weight += weight
+ total_score += signal_score * weight * conf_factor
+ score_weight += weight * conf_factor
+ weight_sum += weight
total_confidence += ind.confidence * weight
- if total_weight > 0:
- normalized_score = (total_score / total_weight) * 50 # scale to -100..+100
- avg_confidence = total_confidence / total_weight
+ if score_weight > 0:
+ # Confidence-weighted average of signal_score (∈ [-2,+2]); ×50 → [-100,+100]
+ normalized_score = (total_score / score_weight) * 50
+ avg_confidence = total_confidence / weight_sum if weight_sum > 0 else 0
else:
normalized_score = 0
avg_confidence = 0
diff --git a/signal_aggregator.py b/signal_aggregator.py
index aba7ed2..74558de 100644
--- a/signal_aggregator.py
+++ b/signal_aggregator.py
@@ -261,20 +261,21 @@ class SignalAggregator:
confidence = report.primary_confidence
# ── MTF adjustment ─────────────────────────────────────────
+ # Always blend primary with MTF average so the score is pulled toward
+ # consensus on disagreement (and barely moves on agreement, since the
+ # TFs already point the same way).
+ all_scores = list(report.mtf_scores.values())
+ if all_scores:
+ mtf_avg = sum(all_scores) / len(all_scores)
+ score = score * 0.6 + mtf_avg * 0.4
+
if report.mtf_agreement:
- # All timeframes agree — boost confidence and blend scores
confidence = min(confidence + 15, 95)
- all_scores = list(report.mtf_scores.values())
- if all_scores:
- mtf_avg = sum(all_scores) / len(all_scores)
- score = score * 0.6 + mtf_avg * 0.4
else:
- # Timeframes disagree — reduce confidence
confidence *= 0.7
if len(report.mtf_scores) >= 2:
- scores = list(report.mtf_scores.values())
- has_bull = any(s > 10 for s in scores)
- has_bear = any(s < -10 for s in scores)
+ has_bull = any(s > 10 for s in report.mtf_scores.values())
+ has_bear = any(s < -10 for s in report.mtf_scores.values())
if has_bull and has_bear:
report.warnings.append("Timeframes CONFLICT — mixed signals across timeframes")
confidence *= 0.6
diff --git a/telegram_bot.py b/telegram_bot.py
index a639d97..4d01f9a 100644
--- a/telegram_bot.py
+++ b/telegram_bot.py
@@ -54,6 +54,12 @@ SCAN_TIMEFRAMES = ["M15", "H1"] # two cards (M15 and H1) per /scan; cache share
# to keep credits low and signal quality high.
SCAN_PAIRS = ["EURUSD=X", "USDJPY=X", "GBPUSD=X", "AUDUSD=X"]
+# Auto-scan job: fires every AUTO_SCAN_INTERVAL seconds and pushes any A/A+
+# signal to ALERT_CHAT_ID (env var). 10 min cadence ≈ 595 credits/day on
+# cold-cache average — fits inside the 800/day Twelve Data free-tier budget.
+AUTO_SCAN_INTERVAL = 600
+ALERT_GRADES = {"A+", "A"}
+
_TF_MAP = {
"M5": Timeframe.M5, "M15": Timeframe.M15,
"M30": Timeframe.M30, "H1": Timeframe.H1,
@@ -265,6 +271,109 @@ def _build_scan_message(
return "\n".join(lines)
+def _build_alert_card(tf: str, r: SignalReport, now_str: str) -> str:
+ """
+ Auto-scan alert message — focused on actionable trade info plus a
+ prominent news/event block (the user wants news context surfaced
+ on every A-grade alert).
+ """
+ lines: List[str] = []
+ sym = _grade_symbol(r.signal_quality)
+ action = _signal_action(r.final_signal)
+
+ lines.append(
+ f"{sym} {_h(r.signal_quality)} ALERT · {_h(r.display_name)}"
+ f" · {action} · {tf}"
+ )
+ lines.append(f"🕐 {now_str}")
+ lines.append("")
+
+ # ── Trade setup ──────────────────────────────────────────────
+ if r.trade_setup:
+ ts = r.trade_setup
+ lines.append(f"Entry: {ts.entry_price:.5f}")
+ lines.append(
+ f"Take Profit: {ts.take_profit_1:.5f}"
+ f" (+{ts.reward_pips:.1f} pips)"
+ )
+ if abs(ts.take_profit_2 - ts.take_profit_1) > 1e-8:
+ lines.append(f"Take Profit 2: {ts.take_profit_2:.5f}")
+ lines.append(
+ f"Stop Loss: {ts.stop_loss:.5f}"
+ f" (-{ts.risk_pips:.1f} pips)"
+ )
+ lines.append(f"Risk:Reward: 1:{ts.risk_reward:.1f}")
+ lines.append("")
+ else:
+ lines.append(f"Current Price: {r.current_price:.5f}")
+ lines.append("")
+
+ # ── Top confluence reasons (top 3, direction-matched) ────────
+ if r.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY):
+ reasons = r.bullish_reasons[:3]
+ elif r.final_signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL):
+ reasons = r.bearish_reasons[:3]
+ else:
+ reasons = []
+ if reasons:
+ lines.append("📊 Confluence:")
+ for reason in reasons:
+ lines.append(f" • {_h(reason)}")
+ lines.append("")
+
+ # ── News & Events (always shown on alerts) ───────────────────
+ lines.append("📰 News & Events:")
+
+ sentiment = r.news_sentiment
+ if sentiment > 0.2:
+ sent_label = f"📈 Bullish news bias (score {sentiment:+.2f})"
+ elif sentiment < -0.2:
+ sent_label = f"📉 Bearish news bias (score {sentiment:+.2f})"
+ else:
+ sent_label = f"📊 Neutral news (score {sentiment:+.2f})"
+ lines.append(f" {sent_label}")
+ if r.news_description and r.news_description.strip().lower() not in (
+ "", "neutral news sentiment", "no recent news",
+ ):
+ lines.append(f" {_h(r.news_description)}")
+
+ risk_label = {
+ "clear": "✅ Clear (no major events nearby)",
+ "caution": "⚠️ Caution — upcoming event",
+ "danger": "🚨 Danger — high-impact event imminent",
+ }.get(r.event_risk, _h(r.event_risk))
+ lines.append(f" Event Risk: {risk_label}")
+ if r.event_description:
+ lines.append(f" {_h(r.event_description)}")
+
+ # Sentiment vs technical alignment hint — useful one-liner for the trader
+ if abs(sentiment) > 0.2:
+ score_dir = 1 if r.final_score > 0 else -1 if r.final_score < 0 else 0
+ sent_dir = 1 if sentiment > 0 else -1
+ if score_dir != 0 and score_dir == sent_dir:
+ lines.append(" ✅ News aligns with technical signal")
+ elif score_dir != 0:
+ lines.append(" ⚠️ News conflicts with technical signal")
+ lines.append("")
+
+ # ── MTF / regime / vol summary ────────────────────────────────
+ agree_str = "✅ Aligned" if r.mtf_agreement else "⚠️ Divergent"
+ lines.append(
+ f"MTF: {agree_str} | Score: {r.final_score:+.1f}"
+ f" | Confidence: {r.final_confidence:.0f}%"
+ f" | {_h(r.market_regime)} | Vol: {_h(r.volatility_regime)}"
+ )
+
+ # ── Critical warnings ────────────────────────────────────────
+ for w in r.warnings:
+ if any(k in w for k in ("EVENT RISK", "CONFLICT", "EXTREME")):
+ lines.append(f"⚠️ {_h(w)}")
+
+ lines.append("")
+ lines.append("⚠️ Educational use only. Not financial advice.")
+ return "\n".join(lines)
+
+
# ══════════════════════════════════════════════════════════════════════
# Bot
# ══════════════════════════════════════════════════════════════════════
@@ -279,6 +388,11 @@ class ForexTelegramBot:
# re-fetching the same bars and waits up to 60s on the rate limit.
self._provider = ForexDataProvider(PlatformConfig())
+ # Dedup keys for the auto-scan alert job: (symbol, tf, direction,
+ # candle_open_timestamp). New bar → new key → re-alert allowed if the
+ # setup persists. Bounded to avoid unbounded growth on long uptime.
+ self._alerted_keys: set = set()
+
# ── Engine helpers ────────────────────────────────────────────────
def _build_config(self, tf: str, pairs: List[str]) -> PlatformConfig:
@@ -347,6 +461,73 @@ class ForexTelegramBot:
async def cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
await self.cmd_help(update, context)
+ async def cmd_myid(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
+ """
+ Returns the current chat's ID so the operator can set ALERT_CHAT_ID
+ in .env and receive auto-scan alerts here.
+ """
+ chat = update.effective_chat
+ text = (
+ f"Chat ID: {chat.id}\n"
+ f"Type: {chat.type}\n\n"
+ "Set ALERT_CHAT_ID=<id> in .env "
+ "and restart the bot to receive A-grade alerts here."
+ )
+ await update.message.reply_text(text, parse_mode=ParseMode.HTML)
+
+ # ── Auto-scan job (every AUTO_SCAN_INTERVAL seconds) ─────────────
+
+ async def _auto_scan_job(self, context: ContextTypes.DEFAULT_TYPE):
+ """
+ Periodic scan that pushes A/A+ alerts to the configured chat.
+ Dedup: one alert per (symbol, tf, direction, candle_open) — so a
+ persistent setup re-alerts once per new bar, not every 10 min.
+ """
+ chat_id = context.job.data["chat_id"]
+
+ try:
+ results_by_tf = await self._analyze_all_tfs(list(SCAN_PAIRS))
+ except Exception as e:
+ logger.error(f"Auto-scan failed: {e}", exc_info=True)
+ return
+
+ now_str = _now_est()
+ sent = 0
+ for tf, reports in results_by_tf.items():
+ for r in reports:
+ if r.signal_quality not in ALERT_GRADES:
+ continue
+ # Skip if engine blocked it via event danger
+ if r.event_risk == "danger":
+ continue
+ ts = r.primary_analysis.timestamp if r.primary_analysis else ""
+ key = (r.symbol, tf, r.final_signal.name, ts)
+ if key in self._alerted_keys:
+ continue
+ self._alerted_keys.add(key)
+
+ text = _build_alert_card(tf, r, now_str)
+ try:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text=text,
+ parse_mode=ParseMode.HTML,
+ )
+ sent += 1
+ logger.info(
+ f"Alert sent: {r.symbol} {tf} {r.final_signal.name} "
+ f"({r.signal_quality})"
+ )
+ except Exception as e:
+ logger.error(f"Failed to send alert for {r.symbol} {tf}: {e}")
+
+ # Bound dedup memory: keep most recent 256 keys
+ if len(self._alerted_keys) > 256:
+ self._alerted_keys = set(list(self._alerted_keys)[-256:])
+
+ if sent == 0:
+ logger.debug("Auto-scan complete: no new A-grade signals")
+
async def cmd_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
tfs = " · ".join(SCAN_TIMEFRAMES)
text = (
@@ -479,6 +660,7 @@ class ForexTelegramBot:
await app.bot.set_my_commands([
BotCommand("signal", "Signal analysis for one pair — /signal EURUSD"),
BotCommand("scan", "Scan all pairs, best Grade B+ per pair"),
+ BotCommand("myid", "Show this chat's ID (for ALERT_CHAT_ID setup)"),
BotCommand("help", "Commands and grade guide"),
])
@@ -493,6 +675,37 @@ class ForexTelegramBot:
app.add_handler(CommandHandler("help", self.cmd_help))
app.add_handler(CommandHandler("signal", self.cmd_signal))
app.add_handler(CommandHandler("scan", self.cmd_scan))
+ app.add_handler(CommandHandler("myid", self.cmd_myid))
+
+ # ── Auto-scan job: only enabled when ALERT_CHAT_ID is set ─────
+ alert_chat_raw = os.environ.get("ALERT_CHAT_ID")
+ if alert_chat_raw:
+ if app.job_queue is None:
+ logger.warning(
+ "ALERT_CHAT_ID set but JobQueue is unavailable. "
+ "Install with: pip install 'python-telegram-bot[job-queue]'"
+ )
+ else:
+ try:
+ chat_id = int(alert_chat_raw)
+ except ValueError:
+ chat_id = alert_chat_raw # channel @username form
+ app.job_queue.run_repeating(
+ self._auto_scan_job,
+ interval=AUTO_SCAN_INTERVAL,
+ first=15, # first run 15s after startup
+ name="auto_scan",
+ data={"chat_id": chat_id},
+ )
+ logger.info(
+ f"Auto-scan enabled: every {AUTO_SCAN_INTERVAL}s → "
+ f"chat {chat_id} (grades: {sorted(ALERT_GRADES)})"
+ )
+ else:
+ logger.info(
+ "Auto-scan disabled — set ALERT_CHAT_ID in .env to enable. "
+ "Use /myid in your target chat to find the ID."
+ )
return app