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:
@@ -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
@@ -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
@@ -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 & 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=<id></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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user