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>
This commit is contained in:
Harwinder
2026-05-06 15:01:35 -04:00
parent 988adf9a3d
commit ced4efa96d
2 changed files with 440 additions and 34 deletions
+5
View File
@@ -19,3 +19,8 @@ Thumbs.db
# Backtest data + outputs (regenerable from seed_history.py / backtest.py) # Backtest data + outputs (regenerable from seed_history.py / backtest.py)
history/ history/
backtest_results/ backtest_results/
backtest_results_*/
# Paper-trade ledger (live state — do not version control)
paper_open.json
paper_trades.csv
+435 -34
View File
@@ -17,14 +17,19 @@ Commands:
""" """
import asyncio import asyncio
import csv
import html import html
import json
import logging import logging
import os import os
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime from datetime import datetime, timezone
from typing import Dict, List, Optional, Tuple from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import pandas as pd
# Load .env so TELEGRAM_BOT_TOKEN / GEMINI_API_KEY / TWELVE_DATA_API_KEY # Load .env so TELEGRAM_BOT_TOKEN / GEMINI_API_KEY / TWELVE_DATA_API_KEY
# don't need to be exported manually. # don't need to be exported manually.
try: try:
@@ -48,17 +53,32 @@ logger = logging.getLogger(__name__)
# ── Config ────────────────────────────────────────────────────────── # ── 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 SCAN_TIMEFRAMES = ["H4"] # H4 only — M15 has no edge
# to keep credits low and signal quality high. SCAN_PAIRS = ["USDCAD=X"] # USDCAD only — only pair with stable edge
SCAN_PAIRS = ["EURUSD=X", "USDJPY=X", "GBPUSD=X", "AUDUSD=X"] ALERT_GRADES = {"B"} # B only — A+/A are anti-edge
# Auto-scan job: fires every AUTO_SCAN_INTERVAL seconds and pushes any A/A+ # 30-min cadence: H4 candles update only every 4 hours, so polling more
# signal to ALERT_CHAT_ID (env var). 10 min cadence ≈ 595 credits/day on # often wastes API credits. 48 scans/day × ~5 credits each ≈ 240/day,
# cold-cache average — fits inside the 800/day Twelve Data free-tier budget. # well inside the 800/day free-tier budget.
AUTO_SCAN_INTERVAL = 600 AUTO_SCAN_INTERVAL = 1800
ALERT_GRADES = {"A+", "A"}
# 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 = { _TF_MAP = {
"M5": Timeframe.M5, "M15": Timeframe.M15, "M5": Timeframe.M5, "M15": Timeframe.M15,
@@ -66,11 +86,11 @@ _TF_MAP = {
"H4": Timeframe.H4, "D1": Timeframe.D1, "H4": Timeframe.H4, "D1": Timeframe.D1,
} }
# D1 kept as confirmation for higher-timeframe trend context. # Confirmation TFs per primary — mirrors backtest.py's mapping
# W1 dropped — only the D1 scan used it, and we no longer scan D1.
_TF_CONFIRMATIONS = { _TF_CONFIRMATIONS = {
"M15": [Timeframe.H1, Timeframe.D1], "M15": [Timeframe.H1, Timeframe.D1],
"H1": [Timeframe.M15, 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_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) sym = _grade_symbol(r.signal_quality)
action = _signal_action(r.final_signal) action = _signal_action(r.final_signal)
paper_tag = "📝 [PAPER] " if PAPER_TRADE_MODE else ""
lines.append( lines.append(
f"{sym} <b>{_h(r.signal_quality)} ALERT · {_h(r.display_name)}" f"{paper_tag}{sym} <b>{_h(r.signal_quality)} ALERT · {_h(r.display_name)}"
f" · {action} · {tf}</b>" f" · {action} · {tf}</b>"
) )
lines.append(f"🕐 {now_str}") 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("") lines.append("")
# ── Trade setup ────────────────────────────────────────────── # ── Trade setup ──────────────────────────────────────────────
@@ -374,6 +400,52 @@ def _build_alert_card(tf: str, r: SignalReport, now_str: str) -> str:
return "\n".join(lines) 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 # Bot
# ══════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════
@@ -381,7 +453,7 @@ def _build_alert_card(tf: str, r: SignalReport, now_str: str) -> str:
class ForexTelegramBot: class ForexTelegramBot:
def __init__(self): def __init__(self):
self._executor = ThreadPoolExecutor( 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 # Persistent ForexDataProvider so the OHLCV cache survives across
# /scan and /signal calls. Without this each call burns API credits # /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. # setup persists. Bounded to avoid unbounded growth on long uptime.
self._alerted_keys: set = set() 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 ──────────────────────────────────────────────── # ── Engine helpers ────────────────────────────────────────────────
def _build_config(self, tf: str, pairs: List[str]) -> PlatformConfig: 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): async def cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
await self.cmd_help(update, context) 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): 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 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) 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) ───────────── # ── Auto-scan job (every AUTO_SCAN_INTERVAL seconds) ─────────────
async def _auto_scan_job(self, context: ContextTypes.DEFAULT_TYPE): async def _auto_scan_job(self, context: ContextTypes.DEFAULT_TYPE):
""" """
Periodic scan that pushes A/A+ alerts to the configured chat. Periodic scan: (1) checks any open paper positions for SL/TP exits,
Dedup: one alert per (symbol, tf, direction, candle_open) — so a (2) emits new alerts for setups in ALERT_GRADES, (3) opens fresh
persistent setup re-alerts once per new bar, not every 10 min. 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"] 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: try:
results_by_tf = await self._analyze_all_tfs(list(SCAN_PAIRS)) results_by_tf = await self._analyze_all_tfs(list(SCAN_PAIRS))
except Exception as e: except Exception as e:
logger.error(f"Auto-scan failed: {e}", exc_info=True) logger.error(f"Auto-scan failed: {e}", exc_info=True)
return 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() now_str = _now_est()
sent = 0 sent = 0
for tf, reports in results_by_tf.items(): for tf, reports in results_by_tf.items():
for r in reports: for r in reports:
if r.signal_quality not in ALERT_GRADES: if r.signal_quality not in ALERT_GRADES:
continue continue
# Skip if engine blocked it via event danger
if r.event_risk == "danger": if r.event_risk == "danger":
continue continue
ts = r.primary_analysis.timestamp if r.primary_analysis else "" ts = r.primary_analysis.timestamp if r.primary_analysis else ""
@@ -514,35 +880,55 @@ class ForexTelegramBot:
parse_mode=ParseMode.HTML, parse_mode=ParseMode.HTML,
) )
sent += 1 sent += 1
self._alert_count += 1
self._last_alert_at = datetime.now(timezone.utc)
logger.info( logger.info(
f"Alert sent: {r.symbol} {tf} {r.final_signal.name} " f"Alert sent: {r.symbol} {tf} {r.final_signal.name} "
f"({r.signal_quality})" f"({r.signal_quality})"
) )
# Open a paper position
if PAPER_TRADE_MODE:
self._open_paper_position(tf, r)
except Exception as e: except Exception as e:
logger.error(f"Failed to send alert for {r.symbol} {tf}: {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: if len(self._alerted_keys) > 256:
self._alerted_keys = set(list(self._alerted_keys)[-256:]) self._alerted_keys = set(list(self._alerted_keys)[-256:])
if sent == 0: 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): async def cmd_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
tfs = " · ".join(SCAN_TIMEFRAMES) 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 = ( text = (
"🌐 <b>Forex Signal Intelligence Platform</b>\n\n" "🌐 <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" "<b>Commands:</b>\n"
" /signal EURUSD — Full signal analysis across all timeframes\n" " /status — auto-scan health + paper P&amp;L\n"
" /scan — Scan all pairs, compact Grade B+ summary\n" " /paper — full paper-trade ledger\n"
" /help — This message\n\n" " /signal EURUSD — full analysis on one pair (any TF)\n"
f"<b>Timeframes analyzed:</b> <code>{tfs}</code>\n\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" "<b>Signal Grades:</b>\n"
" 🔥 A+ — Strongest signal, MTF aligned, high confidence\n" " 🔥 A+ — Highest engine score (⚠️ backtest: anti-edge)\n"
" ✅ A — Strong signal, MTF aligned\n" " ✅ A — High engine score (⚠️ backtest: anti-edge)\n"
" 👍 B — Decent setup, wait for confirmation\n" " 👍 B — Mid-confluence (✅ backtest: where the edge lives)\n"
" ⚠️ C — Weak signal, watch only\n" " ⚠️ C — Weak signal\n"
" 📉 D — Very weak, no action\n" " 📉 D — Very weak\n"
" ❌ F — No clear signal\n\n" " ❌ F — No clear signal\n\n"
"<i>⚠️ Educational use only. Not financial advice.</i>" "<i>⚠️ Educational use only. Not financial advice.</i>"
) )
@@ -658,8 +1044,10 @@ class ForexTelegramBot:
async def _register_commands(app: Application) -> None: async def _register_commands(app: Application) -> None:
await app.bot.set_my_commands([ 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("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("myid", "Show this chat's ID (for ALERT_CHAT_ID setup)"),
BotCommand("help", "Commands and grade guide"), BotCommand("help", "Commands and grade guide"),
]) ])
@@ -675,6 +1063,8 @@ class ForexTelegramBot:
app.add_handler(CommandHandler("help", self.cmd_help)) app.add_handler(CommandHandler("help", self.cmd_help))
app.add_handler(CommandHandler("signal", self.cmd_signal)) app.add_handler(CommandHandler("signal", self.cmd_signal))
app.add_handler(CommandHandler("scan", self.cmd_scan)) 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)) app.add_handler(CommandHandler("myid", self.cmd_myid))
# ── Auto-scan job: only enabled when ALERT_CHAT_ID is set ───── # ── Auto-scan job: only enabled when ALERT_CHAT_ID is set ─────
@@ -713,14 +1103,25 @@ class ForexTelegramBot:
# ══════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════
def main(): 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( logging.basicConfig(
level=logging.WARNING, level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S", datefmt="%H:%M:%S",
) )
for noisy in ("httpx", "httpcore", "telegram", "apscheduler", "asyncio"):
logging.getLogger(noisy).setLevel(logging.WARNING)
bot = ForexTelegramBot() bot = ForexTelegramBot()
app = bot.build_application() 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) app.run_polling(drop_pending_updates=True)