ced4efa96d
Backtest-driven config change. The previous wide-scan setup (4 pairs ×
M15+H1, alert grades A+/A) was empirically losing money in every
backtested permutation. The 2-year backtest identified one robust niche
(USDCAD H4 Grade B: train +20.8 R / test +14.0 R / PF 1.52 / max DD
-9.6 R, positive in every 6-month sub-period). This commit restricts
the live bot to that niche and runs it in paper-trade mode for
validation before any real capital is risked.
Config:
SCAN_PAIRS ["USDCAD=X"] (was 4 pairs)
SCAN_TIMEFRAMES ["H4"] (was M15+H1)
ALERT_GRADES {"B"} (was A+/A — those are anti-edge)
AUTO_SCAN_INTERVAL 1800 (was 600 — H4 candles only print
every 4h, polling more wastes credits)
PAPER_TRADE_MODE True (alerts get [PAPER] prefix and
are NOT for real money)
New machinery:
- _open_paper_position() / _check_paper_positions(): on every scan,
walks any open paper trades forward against the latest H4 bars using
the same first-touch SL/TP1 logic as backtest.simulate_trade.
- paper_open.json + paper_trades.csv: persistent state across restarts.
Open positions survive bot restarts; closed trades append to the CSV.
- /status: uptime, scan count, last scan time, alert count, open paper
positions, win rate, total R, profit factor.
- /paper: full ledger — open trades + last 10 closed.
- INFO-level logging in main() so the user sees scan activity in
stdout instead of wondering whether the bot is alive.
- Help text updated to reflect the calibrated strategy and warn that
A+/A grades are anti-edge in backtest.
- gitignore: paper_open.json + paper_trades.csv + backtest_results_*/
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1130 lines
46 KiB
Python
1130 lines
46 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Forex Signal Intelligence Platform - Telegram Bot
|
||
==================================================
|
||
Trade alert frontend for the signal engine.
|
||
All timeframes are analyzed automatically — no TF parameter needed.
|
||
|
||
Setup:
|
||
set TELEGRAM_BOT_TOKEN=your_token_here (Windows CMD)
|
||
export TELEGRAM_BOT_TOKEN=your_token_here (Linux/Mac)
|
||
python telegram_bot.py
|
||
|
||
Commands:
|
||
/signal EURUSD — Combined signal card across all timeframes
|
||
/scan — Scan all pairs, compact Grade B+ summary
|
||
/help — Command guide
|
||
"""
|
||
|
||
import asyncio
|
||
import csv
|
||
import html
|
||
import json
|
||
import logging
|
||
import os
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
from zoneinfo import ZoneInfo
|
||
|
||
import pandas as pd
|
||
|
||
# Load .env so TELEGRAM_BOT_TOKEN / GEMINI_API_KEY / TWELVE_DATA_API_KEY
|
||
# don't need to be exported manually.
|
||
try:
|
||
from dotenv import load_dotenv
|
||
load_dotenv()
|
||
except ImportError:
|
||
pass
|
||
|
||
from telegram import BotCommand, Update
|
||
from telegram.constants import ParseMode
|
||
from telegram.ext import Application, CommandHandler, ContextTypes
|
||
|
||
from config import (
|
||
MAJOR_PAIRS, CROSS_PAIRS, SYMBOL_DISPLAY,
|
||
PlatformConfig, SignalStrength, Timeframe,
|
||
)
|
||
from data_provider import ForexDataProvider
|
||
from signal_aggregator import SignalAggregator, SignalReport
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ── Config ──────────────────────────────────────────────────────────
|
||
|
||
# ─────────────────────────────────────────────────────────────────────
|
||
# Strategy config — calibrated by 2-year backtest (May 2024 → May 2026)
|
||
# ─────────────────────────────────────────────────────────────────────
|
||
# Backtest verdict: M15 has zero edge across 9,659 trades. Only USDCAD
|
||
# on H4 at Grade B survived 2-year regime stability — train +20.8 R,
|
||
# test +14.0 R, PF 1.52, max DD -9.6 R, positive in every 6-month
|
||
# sub-period. Grades A+/A are anti-edge (highest grade ≠ best trades).
|
||
# See backtest_results_h4_2y/ for the data.
|
||
|
||
SCAN_TIMEFRAMES = ["H4"] # H4 only — M15 has no edge
|
||
SCAN_PAIRS = ["USDCAD=X"] # USDCAD only — only pair with stable edge
|
||
ALERT_GRADES = {"B"} # B only — A+/A are anti-edge
|
||
|
||
# 30-min cadence: H4 candles update only every 4 hours, so polling more
|
||
# often wastes API credits. 48 scans/day × ~5 credits each ≈ 240/day,
|
||
# well inside the 800/day free-tier budget.
|
||
AUTO_SCAN_INTERVAL = 1800
|
||
|
||
# Paper-trade mode: when True, alerts get a [PAPER] prefix and are
|
||
# logged to paper_trades.csv so we can validate the strategy against
|
||
# the backtest distribution before risking real money.
|
||
PAPER_TRADE_MODE = True
|
||
|
||
# Where paper-trade state lives (gitignored)
|
||
_PAPER_OPEN_FILE = Path("paper_open.json")
|
||
_PAPER_TRADES_FILE = Path("paper_trades.csv")
|
||
|
||
_TF_MAP = {
|
||
"M5": Timeframe.M5, "M15": Timeframe.M15,
|
||
"M30": Timeframe.M30, "H1": Timeframe.H1,
|
||
"H4": Timeframe.H4, "D1": Timeframe.D1,
|
||
}
|
||
|
||
# Confirmation TFs per primary — mirrors backtest.py's mapping
|
||
_TF_CONFIRMATIONS = {
|
||
"M15": [Timeframe.H1, Timeframe.D1],
|
||
"H1": [Timeframe.M15, Timeframe.D1],
|
||
"H4": [Timeframe.H1, Timeframe.D1],
|
||
}
|
||
|
||
_GRADE_RANK = {"A+": 0, "A": 1, "B": 2, "C": 3, "D": 4, "F": 5}
|
||
_GRADE_LEGEND = "Grade scale: 🔥 A+ · ✅ A · 👍 B · ⚠️ C · 📉 D · ❌ F"
|
||
_EST = ZoneInfo("America/New_York")
|
||
|
||
# ── Formatting helpers ──────────────────────────────────────────────
|
||
|
||
def _now_est() -> str:
|
||
return datetime.now(_EST).strftime("%I:%M %p EST · %a %d %b %Y")
|
||
|
||
|
||
|
||
def _signal_action(signal: SignalStrength) -> str:
|
||
return {
|
||
SignalStrength.STRONG_BUY: "STRONG BUY",
|
||
SignalStrength.BUY: "BUY",
|
||
SignalStrength.STRONG_SELL: "STRONG SELL",
|
||
SignalStrength.SELL: "SELL",
|
||
SignalStrength.NEUTRAL: "NEUTRAL",
|
||
}.get(signal, "NEUTRAL")
|
||
|
||
|
||
def _grade_symbol(quality: str) -> str:
|
||
return {"A+": "🔥", "A": "✅", "B": "👍", "C": "⚠️", "D": "📉"}.get(quality, "❌")
|
||
|
||
|
||
def _indicator_condition(ind) -> str:
|
||
desc = ind.description
|
||
if " — " in desc:
|
||
return desc.split(" — ", 1)[1].strip()
|
||
if " | " in desc:
|
||
return desc.split(" | ", 1)[0].strip()
|
||
return ind.signal.name.replace("_", " ").title()
|
||
|
||
|
||
def _fmt_float(val: float) -> str:
|
||
if val == 0.0:
|
||
return "0"
|
||
av = abs(val)
|
||
if av >= 1000:
|
||
return f"{val:.1f}"
|
||
if av >= 10:
|
||
return f"{val:.2f}"
|
||
if av >= 0.01:
|
||
return f"{val:.4f}"
|
||
return f"{val:.6f}"
|
||
|
||
|
||
def _h(text: str) -> str:
|
||
return html.escape(str(text))
|
||
|
||
|
||
# ── Message builders ────────────────────────────────────────────────
|
||
|
||
def _build_combined_signal(
|
||
results_by_tf: Dict[str, List[SignalReport]],
|
||
display: str,
|
||
now_str: str,
|
||
) -> str:
|
||
"""
|
||
Single message showing all timeframes stacked.
|
||
Entry/TP/SL always preserved; trimmed: per-card disclaimer,
|
||
MTF detail block (folded to one line), news unless non-neutral.
|
||
"""
|
||
lines: List[str] = []
|
||
lines.append(f"🌐 🧠 <b>{_h(display)} — Signal Analysis</b>")
|
||
lines.append(f"🕐 {now_str}")
|
||
lines.append(f"<i>{_GRADE_LEGEND}</i>")
|
||
|
||
for tf in SCAN_TIMEFRAMES:
|
||
reports = results_by_tf.get(tf, [])
|
||
if not reports:
|
||
continue
|
||
r = reports[0]
|
||
|
||
# If primary TF data was unavailable the engine returns grade F with a warning.
|
||
# Show a clean "data unavailable" line instead of a garbled card.
|
||
if r.signal_quality == "F" and any(
|
||
"unavailable" in w.lower() or "api" in w.lower() or "credits" in w.lower()
|
||
for w in r.warnings
|
||
):
|
||
lines.append("")
|
||
lines.append("────────────────────────")
|
||
lines.append(f"<b>{tf}</b> — ❌ Data unavailable")
|
||
if r.warnings:
|
||
lines.append(f"<i>{_h(r.warnings[0])}</i>")
|
||
continue
|
||
|
||
action = _signal_action(r.final_signal)
|
||
sym = _grade_symbol(r.signal_quality)
|
||
|
||
lines.append("")
|
||
lines.append("────────────────────────")
|
||
lines.append(f"{sym} <b>{tf} · {action} · Grade {_h(r.signal_quality)}</b>")
|
||
lines.append("")
|
||
|
||
# ── Trade setup (always kept intact) ─────────────────────────
|
||
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(f"<b>Analysis:</b> {_h(r.action_recommendation)}")
|
||
lines.append("")
|
||
|
||
# ── Indicators ────────────────────────────────────────────────
|
||
if r.primary_analysis and r.primary_analysis.indicators:
|
||
for name, ind in r.primary_analysis.indicators.items():
|
||
if name.startswith("_") or ind.weight == 0:
|
||
continue
|
||
lines.append(
|
||
f"• {_h(ind.name)}: {_fmt_float(ind.value)}"
|
||
f" ({_h(_indicator_condition(ind))})"
|
||
)
|
||
|
||
# ── MTF summary (one line) ────────────────────────────────────
|
||
agree_str = "✅ Aligned" if r.mtf_agreement else "⚠️ Divergent"
|
||
lines.append(
|
||
f"<i>MTF: {agree_str} | Score: {r.final_score:+.1f}"
|
||
f" | {_h(r.market_regime)} | Vol: {_h(r.volatility_regime)}</i>"
|
||
)
|
||
|
||
# ── Critical warnings only ────────────────────────────────────
|
||
for w in r.warnings:
|
||
if any(k in w for k in ("EVENT RISK", "CONFLICT", "EXTREME")):
|
||
lines.append(f"⚠️ {_h(w)}")
|
||
|
||
# ── Event / news only when relevant ──────────────────────────
|
||
if r.event_risk == "danger" and r.event_description:
|
||
lines.append(f"📅 <b>Event Risk:</b> {_h(r.event_description)}")
|
||
if r.news_description and r.news_description not in ("", "Neutral news sentiment"):
|
||
lines.append(f"📰 {_h(r.news_description)}")
|
||
|
||
lines.append("")
|
||
lines.append("<i>⚠️ Educational use only. Not financial advice.</i>")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _build_scan_message(
|
||
actionable: List[Tuple[str, SignalReport]],
|
||
now_str: str,
|
||
tfs_str: str,
|
||
) -> str:
|
||
"""
|
||
Compact single message: one line header + one trade line per pair.
|
||
Full entry/TP/SL preserved; no indicator list (use /signal to drill in).
|
||
"""
|
||
lines: List[str] = []
|
||
lines.append(f"📊 🧠 <b>Scan Complete</b> · {now_str}")
|
||
lines.append(f"Timeframes: <code>{_h(tfs_str)}</code> | Best signal per pair")
|
||
lines.append(f"<i>{_GRADE_LEGEND}</i>")
|
||
lines.append("")
|
||
|
||
for tf, r in actionable:
|
||
action = _signal_action(r.final_signal)
|
||
sym = _grade_symbol(r.signal_quality)
|
||
|
||
lines.append(
|
||
f"{sym} <b>{_h(r.display_name)} · {action}</b>"
|
||
f" · {_h(r.signal_quality)} · {tf}"
|
||
)
|
||
|
||
if r.trade_setup:
|
||
ts = r.trade_setup
|
||
lines.append(
|
||
f" Entry: {ts.entry_price:.5f} | "
|
||
f"TP: {ts.take_profit_1:.5f} (+{ts.reward_pips:.0f}p) | "
|
||
f"SL: {ts.stop_loss:.5f} (-{ts.risk_pips:.0f}p) | "
|
||
f"R:R 1:{ts.risk_reward:.1f}"
|
||
)
|
||
else:
|
||
lines.append(" Watch only")
|
||
|
||
# Show danger-level events inline
|
||
if r.event_risk == "danger" and r.event_description:
|
||
lines.append(f" 📅 {_h(r.event_description)}")
|
||
|
||
lines.append("")
|
||
|
||
if not actionable:
|
||
lines.append("No Grade B+ signals found across any timeframe.")
|
||
lines.append("")
|
||
|
||
lines.append("<i>⚠️ Educational use only. Not financial advice.</i>")
|
||
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)
|
||
|
||
paper_tag = "📝 [PAPER] " if PAPER_TRADE_MODE else ""
|
||
lines.append(
|
||
f"{paper_tag}{sym} <b>{_h(r.signal_quality)} ALERT · {_h(r.display_name)}"
|
||
f" · {action} · {tf}</b>"
|
||
)
|
||
lines.append(f"🕐 {now_str}")
|
||
if PAPER_TRADE_MODE:
|
||
lines.append(
|
||
"<i>Paper-trade mode — this is logged to paper_trades.csv. "
|
||
"Do NOT trade with real money until validation period ends.</i>"
|
||
)
|
||
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)
|
||
|
||
|
||
def _walk_forward(pos: dict, forward_bars: pd.DataFrame, spread_pips: float) -> tuple:
|
||
"""
|
||
First-touch SL/TP1 simulation on the primary timeframe — same logic as
|
||
backtest.simulate_trade. Returns (outcome, exit_price, exit_ts, bars_held, r_multiple).
|
||
Outcome is OPEN, WIN_TP1, LOSS_SL, or TIMEOUT.
|
||
"""
|
||
if pos["risk_pips"] <= 0:
|
||
return "INVALID", 0.0, None, 0, 0.0
|
||
|
||
spread_r = spread_pips / pos["risk_pips"]
|
||
bars = 0
|
||
for ts, bar in forward_bars.iterrows():
|
||
bars += 1
|
||
if bars > pos["max_hold"]:
|
||
break
|
||
if pos["direction"] == "BUY":
|
||
sl_hit = bar["Low"] <= pos["sl"]
|
||
tp_hit = bar["High"] >= pos["tp1"]
|
||
else:
|
||
sl_hit = bar["High"] >= pos["sl"]
|
||
tp_hit = bar["Low"] <= pos["tp1"]
|
||
|
||
if sl_hit:
|
||
return "LOSS_SL", float(pos["sl"]), ts, bars, -1.0 - spread_r
|
||
if tp_hit:
|
||
r = (pos["reward_pips"] / pos["risk_pips"]) - spread_r
|
||
return "WIN_TP1", float(pos["tp1"]), ts, bars, r
|
||
|
||
if bars == 0:
|
||
return "OPEN", 0.0, None, 0, 0.0
|
||
if bars >= pos["max_hold"]:
|
||
last_idx = bars - 1
|
||
last = forward_bars.iloc[last_idx]
|
||
last_ts = forward_bars.index[last_idx]
|
||
if pos["direction"] == "BUY":
|
||
pnl_price = last["Close"] - pos["entry_price"]
|
||
else:
|
||
pnl_price = pos["entry_price"] - last["Close"]
|
||
sl_dist = abs(pos["entry_price"] - pos["sl"])
|
||
r = (pnl_price / sl_dist) - spread_r if sl_dist > 0 else 0.0
|
||
return "TIMEOUT", float(last["Close"]), last_ts, bars, r
|
||
|
||
# Reached end of available bars without SL/TP/timeout — still open
|
||
return "OPEN", 0.0, None, bars, 0.0
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# Bot
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
class ForexTelegramBot:
|
||
def __init__(self):
|
||
self._executor = ThreadPoolExecutor(
|
||
max_workers=max(1, len(SCAN_TIMEFRAMES)), thread_name_prefix="forex"
|
||
)
|
||
# Persistent ForexDataProvider so the OHLCV cache survives across
|
||
# /scan and /signal calls. Without this each call burns API credits
|
||
# 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()
|
||
|
||
# Auto-scan visibility — exposed by /status
|
||
self._scan_count = 0
|
||
self._alert_count = 0
|
||
self._last_scan_at: Optional[datetime] = None
|
||
self._last_alert_at: Optional[datetime] = None
|
||
self._started_at = datetime.now(timezone.utc)
|
||
|
||
# Paper-trade tracker: open positions persist across restarts via
|
||
# paper_open.json; closed trades append to paper_trades.csv.
|
||
self._paper_open: List[dict] = self._load_paper_open()
|
||
|
||
# ── Engine helpers ────────────────────────────────────────────────
|
||
|
||
def _build_config(self, tf: str, pairs: List[str]) -> PlatformConfig:
|
||
cfg = PlatformConfig()
|
||
cfg.primary_timeframe = _TF_MAP[tf]
|
||
cfg.confirmation_timeframes = _TF_CONFIRMATIONS.get(tf, [Timeframe.M15, Timeframe.D1])
|
||
cfg.watch_pairs = list(pairs)
|
||
return cfg
|
||
|
||
def _run_scan(self, tf: str, pairs: List[str], provider: ForexDataProvider) -> List[SignalReport]:
|
||
return SignalAggregator(self._build_config(tf, pairs), data_provider=provider).analyze_all_pairs()
|
||
|
||
async def _analyze_all_tfs(
|
||
self, pairs: List[str], progress=None,
|
||
) -> Dict[str, List[SignalReport]]:
|
||
"""Single combined prefetch, then run all SCAN_TIMEFRAMES concurrently off the warm cache."""
|
||
# Collect all unique timeframes needed across every TF scan
|
||
all_tfs = list({
|
||
tf
|
||
for tf_name in SCAN_TIMEFRAMES
|
||
for tf in [_TF_MAP[tf_name]] + _TF_CONFIRMATIONS.get(tf_name, [])
|
||
})
|
||
provider = self._provider # reuse across calls — see __init__
|
||
|
||
loop = asyncio.get_running_loop()
|
||
if progress:
|
||
await progress(f"📡 Fetching market data ({len(all_tfs)} timeframes × {len(pairs)} pairs)…")
|
||
# Run blocking prefetch off the event loop so we don't freeze the bot
|
||
await loop.run_in_executor(None, provider.prefetch_batch, list(pairs), all_tfs)
|
||
|
||
if progress:
|
||
await progress("🧠 Analyzing pairs across timeframes…")
|
||
|
||
tasks = {
|
||
tf: loop.run_in_executor(self._executor, self._run_scan, tf, list(pairs), provider)
|
||
for tf in SCAN_TIMEFRAMES
|
||
}
|
||
results: Dict[str, List[SignalReport]] = {}
|
||
for tf, task in tasks.items():
|
||
try:
|
||
results[tf] = await task
|
||
except Exception as e:
|
||
logger.error(f"TF {tf} scan failed: {e}", exc_info=True)
|
||
results[tf] = []
|
||
return results
|
||
|
||
@staticmethod
|
||
async def _send_html(update: Update, text: str):
|
||
"""Send HTML, splitting into chunks at newlines if over 4096 chars."""
|
||
LIMIT = 4000
|
||
chunks: List[str] = []
|
||
while text:
|
||
if len(text) <= LIMIT:
|
||
chunks.append(text)
|
||
break
|
||
split_at = text.rfind("\n", 0, LIMIT)
|
||
if split_at == -1:
|
||
split_at = LIMIT
|
||
chunks.append(text[:split_at])
|
||
text = text[split_at:].lstrip("\n")
|
||
for chunk in chunks:
|
||
await update.message.reply_text(chunk, parse_mode=ParseMode.HTML)
|
||
|
||
# ── Commands ──────────────────────────────────────────────────────
|
||
|
||
async def cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
await self.cmd_help(update, context)
|
||
|
||
async def cmd_status(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""
|
||
/status — proves the auto-scan is alive and shows a summary.
|
||
Reports: uptime, scan count, last scan time, alert count, open
|
||
paper positions, paper-trade P&L (R-units).
|
||
"""
|
||
now = datetime.now(timezone.utc)
|
||
uptime = now - self._started_at
|
||
h, rem = divmod(int(uptime.total_seconds()), 3600)
|
||
m, _ = divmod(rem, 60)
|
||
|
||
last_scan = (
|
||
f"{(now - self._last_scan_at).total_seconds()/60:.1f} min ago "
|
||
f"({self._last_scan_at.strftime('%H:%M:%S UTC')})"
|
||
if self._last_scan_at else "never (waiting for first scan)"
|
||
)
|
||
last_alert = (
|
||
f"{(now - self._last_alert_at).total_seconds()/60:.1f} min ago"
|
||
if self._last_alert_at else "no alerts yet"
|
||
)
|
||
|
||
# Paper P&L summary
|
||
paper_summary = self._paper_summary_lines()
|
||
|
||
text = (
|
||
f"🤖 <b>Bot Status</b>\n\n"
|
||
f"<b>Strategy:</b> {SCAN_PAIRS} on {SCAN_TIMEFRAMES} · "
|
||
f"grades {sorted(ALERT_GRADES)}"
|
||
f"{' · 📝 PAPER MODE' if PAPER_TRADE_MODE else ''}\n"
|
||
f"<b>Cadence:</b> every {AUTO_SCAN_INTERVAL//60} min\n\n"
|
||
f"<b>Uptime:</b> {h}h {m}m\n"
|
||
f"<b>Scans run:</b> {self._scan_count}\n"
|
||
f"<b>Last scan:</b> {last_scan}\n"
|
||
f"<b>Total alerts:</b> {self._alert_count}\n"
|
||
f"<b>Last alert:</b> {last_alert}\n\n"
|
||
f"{paper_summary}"
|
||
)
|
||
await update.message.reply_text(text, parse_mode=ParseMode.HTML)
|
||
|
||
async def cmd_paper(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""
|
||
/paper — full paper-trade ledger summary: open positions and last 10 closed.
|
||
"""
|
||
text = self._paper_full_report()
|
||
await update.message.reply_text(text, parse_mode=ParseMode.HTML)
|
||
|
||
def _paper_summary_lines(self) -> str:
|
||
"""Short paper-trade stats block for /status."""
|
||
n_open = len(self._paper_open)
|
||
if not _PAPER_TRADES_FILE.exists():
|
||
return (
|
||
f"<b>Paper trades:</b>\n"
|
||
f" Open: {n_open}\n"
|
||
f" Closed: 0 (no trades closed yet)"
|
||
)
|
||
try:
|
||
df = pd.read_csv(_PAPER_TRADES_FILE)
|
||
except Exception:
|
||
return f"<b>Paper trades:</b>\n Open: {n_open} (csv unreadable)"
|
||
if df.empty:
|
||
return f"<b>Paper trades:</b>\n Open: {n_open}\n Closed: 0"
|
||
n = len(df)
|
||
wr = (df["r_multiple"] > 0).mean() * 100
|
||
total_r = df["r_multiple"].sum()
|
||
avg_r = df["r_multiple"].mean()
|
||
return (
|
||
f"<b>Paper trades:</b>\n"
|
||
f" Open: {n_open}\n"
|
||
f" Closed: {n} · WR {wr:.1f}% · total R <b>{total_r:+.2f}</b> · avg R {avg_r:+.3f}\n"
|
||
f"<i>(backtest expectation: ~+0.30 R/trade for USDCAD H4 B)</i>"
|
||
)
|
||
|
||
def _paper_full_report(self) -> str:
|
||
lines = ["📝 <b>Paper-trade ledger</b>", ""]
|
||
|
||
# Open
|
||
if self._paper_open:
|
||
lines.append(f"<b>Open ({len(self._paper_open)}):</b>")
|
||
for p in self._paper_open:
|
||
lines.append(
|
||
f" • {_h(p['pair'])} {p['tf']} {p['direction']} "
|
||
f"@ {p['entry_price']:.5f} "
|
||
f"SL {p['sl']:.5f} / TP {p['tp1']:.5f} "
|
||
f"({p.get('bars_held', 0)}/{p['max_hold']} bars)"
|
||
)
|
||
else:
|
||
lines.append("<b>Open:</b> none")
|
||
lines.append("")
|
||
|
||
# Closed (last 10)
|
||
if not _PAPER_TRADES_FILE.exists():
|
||
lines.append("<b>Closed:</b> none yet")
|
||
return "\n".join(lines)
|
||
|
||
try:
|
||
df = pd.read_csv(_PAPER_TRADES_FILE)
|
||
except Exception as e:
|
||
lines.append(f"<b>Closed:</b> error reading {_PAPER_TRADES_FILE}: {e}")
|
||
return "\n".join(lines)
|
||
|
||
if df.empty:
|
||
lines.append("<b>Closed:</b> none yet")
|
||
return "\n".join(lines)
|
||
|
||
n = len(df)
|
||
wr = (df["r_multiple"] > 0).mean() * 100
|
||
total_r = df["r_multiple"].sum()
|
||
avg_r = df["r_multiple"].mean()
|
||
gross_win = df.loc[df["r_multiple"] > 0, "r_multiple"].sum()
|
||
gross_loss = -df.loc[df["r_multiple"] <= 0, "r_multiple"].sum()
|
||
pf = gross_win / gross_loss if gross_loss > 0 else float("inf")
|
||
|
||
lines.append(
|
||
f"<b>Closed ({n}):</b> WR {wr:.1f}% · totalR <b>{total_r:+.2f}</b> · "
|
||
f"avgR {avg_r:+.3f} · PF {pf:.2f}"
|
||
)
|
||
lines.append("")
|
||
lines.append("<b>Last 10 closed:</b>")
|
||
recent = df.tail(10)
|
||
for _, row in recent.iterrows():
|
||
emoji = "✅" if row["r_multiple"] > 0 else "❌"
|
||
lines.append(
|
||
f" {emoji} {_h(row['pair'])} {row['tf']} {row['direction']} "
|
||
f"{row['outcome']} R={row['r_multiple']:+.2f}"
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
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)
|
||
|
||
# ── Paper-trade tracker ──────────────────────────────────────────
|
||
|
||
@staticmethod
|
||
def _load_paper_open() -> List[dict]:
|
||
if not _PAPER_OPEN_FILE.exists():
|
||
return []
|
||
try:
|
||
return json.loads(_PAPER_OPEN_FILE.read_text())
|
||
except Exception as e:
|
||
logger.warning(f"Could not load {_PAPER_OPEN_FILE}: {e}")
|
||
return []
|
||
|
||
def _save_paper_open(self) -> None:
|
||
try:
|
||
_PAPER_OPEN_FILE.write_text(json.dumps(self._paper_open, indent=2))
|
||
except Exception as e:
|
||
logger.error(f"Could not save {_PAPER_OPEN_FILE}: {e}")
|
||
|
||
def _append_closed_trade(self, row: dict) -> None:
|
||
"""Append one closed-trade row to paper_trades.csv (header on first write)."""
|
||
write_header = not _PAPER_TRADES_FILE.exists()
|
||
try:
|
||
with _PAPER_TRADES_FILE.open("a", newline="") as f:
|
||
w = csv.DictWriter(f, fieldnames=list(row.keys()))
|
||
if write_header:
|
||
w.writeheader()
|
||
w.writerow(row)
|
||
except Exception as e:
|
||
logger.error(f"Could not append to {_PAPER_TRADES_FILE}: {e}")
|
||
|
||
def _open_paper_position(self, tf: str, r: SignalReport) -> None:
|
||
"""Record a fresh alert as an open paper position."""
|
||
if r.trade_setup is None:
|
||
return
|
||
ts = r.trade_setup
|
||
bar_ts = r.primary_analysis.timestamp if r.primary_analysis else ""
|
||
pos = {
|
||
"alert_id": f"{r.symbol}|{tf}|{ts.direction}|{bar_ts}",
|
||
"symbol": r.symbol,
|
||
"pair": r.display_name,
|
||
"tf": tf,
|
||
"grade": r.signal_quality,
|
||
"direction": ts.direction,
|
||
"entry_time": bar_ts, # bar's close timestamp
|
||
"entry_price": ts.entry_price,
|
||
"sl": ts.stop_loss,
|
||
"tp1": ts.take_profit_1,
|
||
"tp2": ts.take_profit_2,
|
||
"risk_pips": ts.risk_pips,
|
||
"reward_pips": ts.reward_pips,
|
||
"score": r.final_score,
|
||
"confidence": r.final_confidence,
|
||
"regime": r.market_regime,
|
||
"mtf_aligned": bool(r.mtf_agreement),
|
||
"max_hold": 100,
|
||
"status": "OPEN",
|
||
}
|
||
# Don't double-open if alert_id already in flight
|
||
if any(p["alert_id"] == pos["alert_id"] for p in self._paper_open):
|
||
return
|
||
self._paper_open.append(pos)
|
||
self._save_paper_open()
|
||
logger.info(f"📝 paper_open: {pos['pair']} {tf} {ts.direction} @ {ts.entry_price:.5f}")
|
||
|
||
async def _check_paper_positions(self, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||
"""
|
||
Walk forward each open paper position using the warm OHLCV cache.
|
||
First-touch SL/TP1 — same logic as backtest.simulate_trade. Closed
|
||
positions are appended to paper_trades.csv and a brief notification
|
||
is sent to the alert chat.
|
||
"""
|
||
if not self._paper_open:
|
||
return
|
||
|
||
chat_id = context.job.data["chat_id"]
|
||
loop = asyncio.get_running_loop()
|
||
still_open: List[dict] = []
|
||
spread_pips = 1.0
|
||
|
||
for pos in self._paper_open:
|
||
symbol = pos["symbol"]
|
||
tf_enum = _TF_MAP[pos["tf"]]
|
||
try:
|
||
df = await loop.run_in_executor(
|
||
None, self._provider.fetch_ohlcv, symbol, tf_enum,
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"check_paper: {symbol} fetch failed: {e}")
|
||
still_open.append(pos)
|
||
continue
|
||
|
||
entry_ts = pd.Timestamp(pos["entry_time"])
|
||
if entry_ts.tz is None:
|
||
entry_ts = entry_ts.tz_localize("UTC")
|
||
forward = df[df.index > entry_ts].head(pos["max_hold"])
|
||
if forward.empty:
|
||
still_open.append(pos)
|
||
continue
|
||
|
||
outcome, exit_price, exit_ts, bars_held, r_mult = _walk_forward(
|
||
pos, forward, spread_pips,
|
||
)
|
||
|
||
if outcome == "OPEN":
|
||
pos["bars_held"] = bars_held
|
||
still_open.append(pos)
|
||
continue
|
||
|
||
# Position closed — log to CSV and notify
|
||
closed = dict(pos)
|
||
closed["status"] = "CLOSED"
|
||
closed["outcome"] = outcome
|
||
closed["exit_time"] = str(exit_ts)
|
||
closed["exit_price"] = exit_price
|
||
closed["bars_held"] = bars_held
|
||
closed["r_multiple"] = round(r_mult, 4)
|
||
self._append_closed_trade(closed)
|
||
logger.info(
|
||
f"📊 paper_close: {pos['pair']} {pos['tf']} {pos['direction']} "
|
||
f"{outcome} R={r_mult:+.2f}"
|
||
)
|
||
|
||
# Notify chat
|
||
emoji = "✅" if r_mult > 0 else "❌"
|
||
msg = (
|
||
f"{emoji} <b>[PAPER] Position closed</b>\n"
|
||
f"{_h(pos['pair'])} {pos['tf']} {pos['direction']}\n"
|
||
f"Entry {pos['entry_price']:.5f} → Exit {exit_price:.5f}\n"
|
||
f"Outcome: <b>{outcome}</b> · R = <b>{r_mult:+.2f}</b>\n"
|
||
f"Held {bars_held} {pos['tf']} bars"
|
||
)
|
||
try:
|
||
await context.bot.send_message(
|
||
chat_id=chat_id, text=msg, parse_mode=ParseMode.HTML,
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"close-notify failed: {e}")
|
||
|
||
self._paper_open = still_open
|
||
self._save_paper_open()
|
||
|
||
# ── Auto-scan job (every AUTO_SCAN_INTERVAL seconds) ─────────────
|
||
|
||
async def _auto_scan_job(self, context: ContextTypes.DEFAULT_TYPE):
|
||
"""
|
||
Periodic scan: (1) checks any open paper positions for SL/TP exits,
|
||
(2) emits new alerts for setups in ALERT_GRADES, (3) opens fresh
|
||
paper positions for every alert.
|
||
|
||
Dedup: one alert per (symbol, tf, direction, candle_open) — a
|
||
persistent setup re-alerts once per new bar, not every scan tick.
|
||
"""
|
||
chat_id = context.job.data["chat_id"]
|
||
|
||
# 1. Walk open paper positions forward against the latest bars
|
||
await self._check_paper_positions(context)
|
||
|
||
# 2. Run the engine
|
||
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
|
||
|
||
# 3. Update visibility counters
|
||
self._scan_count += 1
|
||
self._last_scan_at = datetime.now(timezone.utc)
|
||
logger.info(
|
||
f"auto_scan #{self._scan_count}: scanned {SCAN_PAIRS} on "
|
||
f"{SCAN_TIMEFRAMES}; open paper positions: {len(self._paper_open)}"
|
||
)
|
||
|
||
# 4. Emit alerts for graded setups
|
||
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
|
||
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
|
||
self._alert_count += 1
|
||
self._last_alert_at = datetime.now(timezone.utc)
|
||
logger.info(
|
||
f"Alert sent: {r.symbol} {tf} {r.final_signal.name} "
|
||
f"({r.signal_quality})"
|
||
)
|
||
# Open a paper position
|
||
if PAPER_TRADE_MODE:
|
||
self._open_paper_position(tf, r)
|
||
except Exception as e:
|
||
logger.error(f"Failed to send alert for {r.symbol} {tf}: {e}")
|
||
|
||
if len(self._alerted_keys) > 256:
|
||
self._alerted_keys = set(list(self._alerted_keys)[-256:])
|
||
|
||
if sent == 0:
|
||
logger.debug(f"auto_scan #{self._scan_count}: no new alerts")
|
||
|
||
async def cmd_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
tfs = " · ".join(SCAN_TIMEFRAMES)
|
||
pairs = " · ".join(p.replace("=X", "") for p in SCAN_PAIRS)
|
||
grades = " · ".join(sorted(ALERT_GRADES))
|
||
paper_note = (
|
||
"📝 <b>Paper-trade mode is ACTIVE.</b> "
|
||
"Alerts are tagged [PAPER] and logged to paper_trades.csv. "
|
||
"Do NOT use real capital until validation period ends.\n\n"
|
||
if PAPER_TRADE_MODE else ""
|
||
)
|
||
text = (
|
||
"🌐 <b>Forex Signal Intelligence Platform</b>\n\n"
|
||
f"{paper_note}"
|
||
"<b>Strategy (calibrated by 2-yr backtest):</b>\n"
|
||
f" Pair: <code>{pairs}</code>\n"
|
||
f" Timeframe: <code>{tfs}</code>\n"
|
||
f" Alert grades: <code>{grades}</code>\n"
|
||
f" Auto-scan: every {AUTO_SCAN_INTERVAL//60} min\n\n"
|
||
"<b>Commands:</b>\n"
|
||
" /status — auto-scan health + paper P&L\n"
|
||
" /paper — full paper-trade ledger\n"
|
||
" /signal EURUSD — full analysis on one pair (any TF)\n"
|
||
" /scan — manual scan of configured pairs\n"
|
||
" /myid — chat ID (for ALERT_CHAT_ID setup)\n"
|
||
" /help — this message\n\n"
|
||
"<b>Signal Grades:</b>\n"
|
||
" 🔥 A+ — Highest engine score (⚠️ backtest: anti-edge)\n"
|
||
" ✅ A — High engine score (⚠️ backtest: anti-edge)\n"
|
||
" 👍 B — Mid-confluence (✅ backtest: where the edge lives)\n"
|
||
" ⚠️ C — Weak signal\n"
|
||
" 📉 D — Very weak\n"
|
||
" ❌ F — No clear signal\n\n"
|
||
"<i>⚠️ Educational use only. Not financial advice.</i>"
|
||
)
|
||
await update.message.reply_text(text, parse_mode=ParseMode.HTML)
|
||
|
||
async def cmd_signal(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""
|
||
/signal EURUSD
|
||
Runs all timeframes concurrently, returns a single combined message
|
||
with one stacked section per TF. Entry/TP/SL always preserved.
|
||
"""
|
||
args = context.args or []
|
||
if not args:
|
||
await update.message.reply_text(
|
||
"Usage: <b>/signal <pair></b>\n"
|
||
"Example: <code>/signal EURUSD</code>",
|
||
parse_mode=ParseMode.HTML,
|
||
)
|
||
return
|
||
|
||
raw = args[0].upper().replace("/", "").replace("_", "")
|
||
if not raw.endswith("=X"):
|
||
raw += "=X"
|
||
display = SYMBOL_DISPLAY.get(raw, raw.replace("=X", ""))
|
||
tfs_str = " · ".join(SCAN_TIMEFRAMES)
|
||
|
||
msg = await update.message.reply_text(
|
||
f"🔄 Analyzing {_h(display)} across {_h(tfs_str)}…"
|
||
)
|
||
|
||
async def progress(text: str):
|
||
try:
|
||
await msg.edit_text(text)
|
||
except Exception:
|
||
pass # ignore edit-rate errors / no-change errors
|
||
|
||
try:
|
||
results_by_tf = await self._analyze_all_tfs([raw], progress=progress)
|
||
now_str = _now_est()
|
||
|
||
# Check we got at least one result
|
||
if not any(results_by_tf.values()):
|
||
await msg.edit_text(f"❌ No data returned for {_h(display)}")
|
||
return
|
||
|
||
text = _build_combined_signal(results_by_tf, display, now_str)
|
||
await msg.delete()
|
||
await self._send_html(update, text)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Signal error for {raw}: {e}", exc_info=True)
|
||
await msg.edit_text(f"❌ Analysis failed: {_h(str(e))}")
|
||
|
||
async def cmd_scan(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""
|
||
/scan
|
||
Analyzes all major pairs across all timeframes concurrently.
|
||
Picks the best-grade result per pair, returns a single compact message.
|
||
"""
|
||
tfs_str = " · ".join(SCAN_TIMEFRAMES)
|
||
|
||
msg = await update.message.reply_text(
|
||
f"🔄 Scanning all pairs across {_h(tfs_str)}…"
|
||
)
|
||
|
||
async def progress(text: str):
|
||
try:
|
||
await msg.edit_text(text)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
results_by_tf = await self._analyze_all_tfs(list(SCAN_PAIRS), progress=progress)
|
||
now_str = _now_est()
|
||
|
||
# Best grade per pair across all TFs
|
||
best_per_pair: Dict[str, Tuple[str, SignalReport]] = {}
|
||
for tf, reports in results_by_tf.items():
|
||
for r in reports:
|
||
current = best_per_pair.get(r.symbol)
|
||
if current is None or (
|
||
_GRADE_RANK.get(r.signal_quality, 99)
|
||
< _GRADE_RANK.get(current[1].signal_quality, 99)
|
||
):
|
||
best_per_pair[r.symbol] = (tf, r)
|
||
|
||
# Filter B+ and sort best-first
|
||
actionable = [
|
||
(tf, r)
|
||
for tf, r in best_per_pair.values()
|
||
if r.signal_quality in ("A+", "A", "B")
|
||
]
|
||
actionable.sort(key=lambda x: _GRADE_RANK.get(x[1].signal_quality, 99))
|
||
|
||
text = _build_scan_message(actionable, now_str, tfs_str)
|
||
await msg.edit_text(text, parse_mode=ParseMode.HTML)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Scan error: {e}", exc_info=True)
|
||
await msg.edit_text(f"❌ Scan failed: {_h(str(e))}")
|
||
|
||
# ── Application builder ───────────────────────────────────────────
|
||
|
||
def build_application(self) -> Application:
|
||
token = os.environ.get("TELEGRAM_BOT_TOKEN")
|
||
if not token:
|
||
raise ValueError(
|
||
"TELEGRAM_BOT_TOKEN environment variable is not set.\n"
|
||
"Get a token from @BotFather on Telegram, then:\n"
|
||
" Windows CMD: set TELEGRAM_BOT_TOKEN=your_token\n"
|
||
" Linux/Mac: export TELEGRAM_BOT_TOKEN=your_token"
|
||
)
|
||
|
||
async def _register_commands(app: Application) -> None:
|
||
await app.bot.set_my_commands([
|
||
BotCommand("status", "Auto-scan status, scan count, paper P&L"),
|
||
BotCommand("paper", "Paper-trade ledger — open positions + last 10 closed"),
|
||
BotCommand("signal", "Signal analysis for one pair — /signal EURUSD"),
|
||
BotCommand("scan", "Manual scan of configured pairs"),
|
||
BotCommand("myid", "Show this chat's ID (for ALERT_CHAT_ID setup)"),
|
||
BotCommand("help", "Commands and grade guide"),
|
||
])
|
||
|
||
app = (
|
||
Application.builder()
|
||
.token(token)
|
||
.post_init(_register_commands)
|
||
.build()
|
||
)
|
||
|
||
app.add_handler(CommandHandler("start", self.cmd_start))
|
||
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("status", self.cmd_status))
|
||
app.add_handler(CommandHandler("paper", self.cmd_paper))
|
||
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
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
def main():
|
||
# INFO level so the user can see auto-scan activity in stdout
|
||
# ("auto_scan #5: scanned ['USDCAD=X'] on ['H4'] …").
|
||
# Drop external libraries to WARNING to keep the log readable.
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||
datefmt="%H:%M:%S",
|
||
)
|
||
for noisy in ("httpx", "httpcore", "telegram", "apscheduler", "asyncio"):
|
||
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||
|
||
bot = ForexTelegramBot()
|
||
app = bot.build_application()
|
||
print("🤖 Forex Signal Bot started.")
|
||
print(f" Strategy: {SCAN_PAIRS} on {SCAN_TIMEFRAMES} · grades {sorted(ALERT_GRADES)}")
|
||
print(f" Auto-scan: every {AUTO_SCAN_INTERVAL//60} min")
|
||
if PAPER_TRADE_MODE:
|
||
print(f" 📝 Paper-trade mode ON — logging to {_PAPER_TRADES_FILE}")
|
||
print(" Ctrl+C to stop.\n")
|
||
app.run_polling(drop_pending_updates=True)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|