Fix scoring math; add 10-min auto-scan A-grade alerts with news block

- engine: composite-score denominator now confidence-weighted, so the
  output actually spans -100..+100 (the prior formula put confidence
  in the numerator only, capping the realistic score around ±25-30
  and making STRONG_BUY ≥40 essentially unreachable).
- engine: ATR percentile uses iloc[-50:-1] to exclude the current bar
  from its own comparison (off-by-one in volatility regime).
- signal_aggregator: always blend primary score with MTF average. The
  prior code only blended on agreement, leaving the primary unblended
  on disagreement — backwards. Disagreement is exactly when the score
  should be pulled toward consensus.
- telegram_bot: JobQueue auto-scan every 10 min that pushes A/A+ to
  ALERT_CHAT_ID with a prominent News & Events block (sentiment +
  headline summary + event-risk + alignment hint). Per-bar dedup so
  persistent setups alert once per new candle, not every 10 min.
  Suppresses alerts when event_risk = "danger".
- telegram_bot: /myid returns chat ID for ALERT_CHAT_ID setup. Auto-scan
  silently disabled when ALERT_CHAT_ID is unset.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Harwinder
2026-05-06 12:50:30 -04:00
parent 3a17471c0e
commit 402d49e094
3 changed files with 235 additions and 17 deletions
+12 -8
View File
@@ -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
+10 -9
View File
@@ -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
+213
View File
@@ -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} <b>{_h(r.signal_quality)} ALERT · {_h(r.display_name)}"
f" · {action} · {tf}</b>"
)
lines.append(f"🕐 {now_str}")
lines.append("")
# ── Trade setup ──────────────────────────────────────────────
if r.trade_setup:
ts = r.trade_setup
lines.append(f"<b>Entry:</b> {ts.entry_price:.5f}")
lines.append(
f"<b>Take Profit:</b> {ts.take_profit_1:.5f}"
f" <i>(+{ts.reward_pips:.1f} pips)</i>"
)
if abs(ts.take_profit_2 - ts.take_profit_1) > 1e-8:
lines.append(f"<b>Take Profit 2:</b> {ts.take_profit_2:.5f}")
lines.append(
f"<b>Stop Loss:</b> {ts.stop_loss:.5f}"
f" <i>(-{ts.risk_pips:.1f} pips)</i>"
)
lines.append(f"<b>Risk:Reward:</b> 1:{ts.risk_reward:.1f}")
lines.append("")
else:
lines.append(f"<b>Current Price:</b> {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("<b>📊 Confluence:</b>")
for reason in reasons:
lines.append(f" • {_h(reason)}")
lines.append("")
# ── News & Events (always shown on alerts) ───────────────────
lines.append("<b>📰 News &amp; Events:</b>")
sentiment = r.news_sentiment
if sentiment > 0.2:
sent_label = f"📈 Bullish news bias <i>(score {sentiment:+.2f})</i>"
elif sentiment < -0.2:
sent_label = f"📉 Bearish news bias <i>(score {sentiment:+.2f})</i>"
else:
sent_label = f"📊 Neutral news <i>(score {sentiment:+.2f})</i>"
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" <i>{_h(r.news_description)}</i>")
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" <i>{_h(r.event_description)}</i>")
# 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"<i>MTF: {agree_str} | Score: {r.final_score:+.1f}"
f" | Confidence: {r.final_confidence:.0f}%"
f" | {_h(r.market_regime)} | Vol: {_h(r.volatility_regime)}</i>"
)
# ── 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("<i>⚠️ Educational use only. Not financial advice.</i>")
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"<b>Chat ID:</b> <code>{chat.id}</code>\n"
f"<b>Type:</b> {chat.type}\n\n"
"Set <code>ALERT_CHAT_ID=&lt;id&gt;</code> in <code>.env</code> "
"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