Files
ForexBot2/telegram_bot.py
T
Harwinder ced4efa96d Narrow bot to USDCAD H4 Grade B + paper-trade tracker + /status command
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>
2026-05-06 15:01:35 -04:00

1130 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 &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)
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=&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)
# ── 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&amp;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 &lt;pair&gt;</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()