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