From ced4efa96d153e9c689235a35fa3568327c195d9 Mon Sep 17 00:00:00 2001 From: Harwinder Date: Wed, 6 May 2026 15:01:35 -0400 Subject: [PATCH] Narrow bot to USDCAD H4 Grade B + paper-trade tracker + /status command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 5 + telegram_bot.py | 469 ++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 440 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index aa79bfb..c6c5c53 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,8 @@ Thumbs.db # Backtest data + outputs (regenerable from seed_history.py / backtest.py) history/ backtest_results/ +backtest_results_*/ + +# Paper-trade ledger (live state — do not version control) +paper_open.json +paper_trades.csv diff --git a/telegram_bot.py b/telegram_bot.py index 4d01f9a..f39ee99 100644 --- a/telegram_bot.py +++ b/telegram_bot.py @@ -17,14 +17,19 @@ Commands: """ import asyncio +import csv import html +import json import logging import os from concurrent.futures import ThreadPoolExecutor -from datetime import datetime -from typing import Dict, List, Optional, Tuple +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: @@ -48,17 +53,32 @@ logger = logging.getLogger(__name__) # ── Config ────────────────────────────────────────────────────────── -SCAN_TIMEFRAMES = ["M15", "H1"] # two cards (M15 and H1) per /scan; cache shared +# ───────────────────────────────────────────────────────────────────── +# 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. -# Pairs analyzed by the /scan command. Limited to the most liquid majors -# to keep credits low and signal quality high. -SCAN_PAIRS = ["EURUSD=X", "USDJPY=X", "GBPUSD=X", "AUDUSD=X"] +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 -# 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"} +# 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, @@ -66,11 +86,11 @@ _TF_MAP = { "H4": Timeframe.H4, "D1": Timeframe.D1, } -# D1 kept as confirmation for higher-timeframe trend context. -# W1 dropped — only the D1 scan used it, and we no longer scan 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} @@ -281,11 +301,17 @@ def _build_alert_card(tf: str, r: SignalReport, now_str: str) -> str: sym = _grade_symbol(r.signal_quality) action = _signal_action(r.final_signal) + paper_tag = "📝 [PAPER] " if PAPER_TRADE_MODE else "" lines.append( - f"{sym} {_h(r.signal_quality)} ALERT · {_h(r.display_name)}" + f"{paper_tag}{sym} {_h(r.signal_quality)} ALERT · {_h(r.display_name)}" f" · {action} · {tf}" ) lines.append(f"🕐 {now_str}") + if PAPER_TRADE_MODE: + lines.append( + "Paper-trade mode — this is logged to paper_trades.csv. " + "Do NOT trade with real money until validation period ends." + ) lines.append("") # ── Trade setup ────────────────────────────────────────────── @@ -374,6 +400,52 @@ def _build_alert_card(tf: str, r: SignalReport, now_str: str) -> str: 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 # ══════════════════════════════════════════════════════════════════════ @@ -381,7 +453,7 @@ def _build_alert_card(tf: str, r: SignalReport, now_str: str) -> str: class ForexTelegramBot: def __init__(self): self._executor = ThreadPoolExecutor( - max_workers=len(SCAN_TIMEFRAMES), thread_name_prefix="forex" + 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 @@ -393,6 +465,17 @@ class ForexTelegramBot: # 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: @@ -461,6 +544,133 @@ class ForexTelegramBot: 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"🤖 Bot Status\n\n" + f"Strategy: {SCAN_PAIRS} on {SCAN_TIMEFRAMES} · " + f"grades {sorted(ALERT_GRADES)}" + f"{' · 📝 PAPER MODE' if PAPER_TRADE_MODE else ''}\n" + f"Cadence: every {AUTO_SCAN_INTERVAL//60} min\n\n" + f"Uptime: {h}h {m}m\n" + f"Scans run: {self._scan_count}\n" + f"Last scan: {last_scan}\n" + f"Total alerts: {self._alert_count}\n" + f"Last alert: {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"Paper trades:\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"Paper trades:\n Open: {n_open} (csv unreadable)" + if df.empty: + return f"Paper trades:\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"Paper trades:\n" + f" Open: {n_open}\n" + f" Closed: {n} · WR {wr:.1f}% · total R {total_r:+.2f} · avg R {avg_r:+.3f}\n" + f"(backtest expectation: ~+0.30 R/trade for USDCAD H4 B)" + ) + + def _paper_full_report(self) -> str: + lines = ["📝 Paper-trade ledger", ""] + + # Open + if self._paper_open: + lines.append(f"Open ({len(self._paper_open)}):") + 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("Open: none") + lines.append("") + + # Closed (last 10) + if not _PAPER_TRADES_FILE.exists(): + lines.append("Closed: none yet") + return "\n".join(lines) + + try: + df = pd.read_csv(_PAPER_TRADES_FILE) + except Exception as e: + lines.append(f"Closed: error reading {_PAPER_TRADES_FILE}: {e}") + return "\n".join(lines) + + if df.empty: + lines.append("Closed: 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"Closed ({n}): WR {wr:.1f}% · totalR {total_r:+.2f} · " + f"avgR {avg_r:+.3f} · PF {pf:.2f}" + ) + lines.append("") + lines.append("Last 10 closed:") + 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 @@ -475,29 +685,185 @@ class ForexTelegramBot: ) 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} [PAPER] Position closed\n" + f"{_h(pos['pair'])} {pos['tf']} {pos['direction']}\n" + f"Entry {pos['entry_price']:.5f} → Exit {exit_price:.5f}\n" + f"Outcome: {outcome} · R = {r_mult:+.2f}\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 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. + 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 - # Skip if engine blocked it via event danger if r.event_risk == "danger": continue ts = r.primary_analysis.timestamp if r.primary_analysis else "" @@ -514,35 +880,55 @@ class ForexTelegramBot: 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}") - # 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") + 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 = ( + "📝 Paper-trade mode is ACTIVE. " + "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 = ( "🌐 Forex Signal Intelligence Platform\n\n" + f"{paper_note}" + "Strategy (calibrated by 2-yr backtest):\n" + f" Pair: {pairs}\n" + f" Timeframe: {tfs}\n" + f" Alert grades: {grades}\n" + f" Auto-scan: every {AUTO_SCAN_INTERVAL//60} min\n\n" "Commands:\n" - " /signal EURUSD — Full signal analysis across all timeframes\n" - " /scan — Scan all pairs, compact Grade B+ summary\n" - " /help — This message\n\n" - f"Timeframes analyzed: {tfs}\n\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" "Signal Grades:\n" - " 🔥 A+ — Strongest signal, MTF aligned, high confidence\n" - " ✅ A — Strong signal, MTF aligned\n" - " 👍 B — Decent setup, wait for confirmation\n" - " ⚠️ C — Weak signal, watch only\n" - " 📉 D — Very weak, no action\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" "⚠️ Educational use only. Not financial advice." ) @@ -658,8 +1044,10 @@ class ForexTelegramBot: 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", "Scan all pairs, best Grade B+ per pair"), + 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"), ]) @@ -675,6 +1063,8 @@ 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("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 ───── @@ -713,14 +1103,25 @@ class ForexTelegramBot: # ══════════════════════════════════════════════════════════════════════ 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.WARNING, - format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + 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. Press Ctrl+C to stop.") + 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)