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:
@@ -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
|
||||
|
||||
+435
-34
@@ -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} <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>"
|
||||
)
|
||||
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 ──────────────────────────────────────────────
|
||||
@@ -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"🤖 <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
|
||||
@@ -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} <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 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 = (
|
||||
"📝 <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"
|
||||
" /signal EURUSD — Full signal analysis across all timeframes\n"
|
||||
" /scan — Scan all pairs, compact Grade B+ summary\n"
|
||||
" /help — This message\n\n"
|
||||
f"<b>Timeframes analyzed:</b> <code>{tfs}</code>\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"
|
||||
"<b>Signal Grades:</b>\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"
|
||||
"<i>⚠️ Educational use only. Not financial advice.</i>"
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user