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)
|
# 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
@@ -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&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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user