Compare commits
4 Commits
402d49e094
...
ced4efa96d
| Author | SHA1 | Date | |
|---|---|---|---|
| ced4efa96d | |||
| 988adf9a3d | |||
| d8b0ba5b47 | |||
| 6d235061b9 |
@@ -15,3 +15,12 @@ venv/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
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
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Phase 1 analysis: post-hoc filters on an existing trades.csv.
|
||||||
|
|
||||||
|
Asks "which simple filters, applied to the trades the engine already
|
||||||
|
produced, would have moved total R toward zero or positive?" — without
|
||||||
|
re-running the backtest engine.
|
||||||
|
|
||||||
|
Discipline: candidate filters are derived on the **first 70% of trades
|
||||||
|
by entry_time** (train slice), then evaluated on the **last 30%** (test
|
||||||
|
slice). This is the same time-based split the meta-model uses, so the
|
||||||
|
numbers are comparable.
|
||||||
|
|
||||||
|
Outputs (printed + saved to backtest_results_all/phase1_summary.txt):
|
||||||
|
- Session-of-day breakdown
|
||||||
|
- Day-of-week breakdown
|
||||||
|
- Pair × regime cross-tab
|
||||||
|
- Per-pair contribution
|
||||||
|
- Combined-filter sweep (drop-worst-hours / drop-worst-pairs / both)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python analyze_trades.py
|
||||||
|
python analyze_trades.py --trades backtest_results_all/trades.csv
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def fmt(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
return df.round(3)
|
||||||
|
|
||||||
|
|
||||||
|
def split(df: pd.DataFrame, train_frac: float = 0.7):
|
||||||
|
df = df.sort_values("entry_time").reset_index(drop=True)
|
||||||
|
cutoff = int(len(df) * train_frac)
|
||||||
|
return df.iloc[:cutoff].copy(), df.iloc[cutoff:].copy()
|
||||||
|
|
||||||
|
|
||||||
|
def stats(df: pd.DataFrame) -> dict:
|
||||||
|
if len(df) == 0:
|
||||||
|
return {"n": 0, "wr": 0.0, "avg_r": 0.0, "total_r": 0.0, "pf": 0.0}
|
||||||
|
wins = (df["r_multiple"] > 0).sum()
|
||||||
|
losses = (df["r_multiple"] <= 0).sum()
|
||||||
|
gross_win = df.loc[df["r_multiple"] > 0, "r_multiple"].sum()
|
||||||
|
gross_loss = -df.loc[df["r_multiple"] <= 0, "r_multiple"].sum()
|
||||||
|
return {
|
||||||
|
"n": len(df),
|
||||||
|
"wr": wins / len(df) * 100,
|
||||||
|
"avg_r": df["r_multiple"].mean(),
|
||||||
|
"total_r": df["r_multiple"].sum(),
|
||||||
|
"pf": gross_win / gross_loss if gross_loss > 0 else float("inf"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def stats_line(label: str, df: pd.DataFrame) -> str:
|
||||||
|
s = stats(df)
|
||||||
|
return (
|
||||||
|
f"{label:<40s}"
|
||||||
|
f" n={s['n']:>5} WR={s['wr']:5.1f}%"
|
||||||
|
f" avgR={s['avg_r']:+6.3f} totalR={s['total_r']:+9.2f} PF={s['pf']:.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="Phase 1 post-hoc analysis on backtest trades.")
|
||||||
|
ap.add_argument("--trades", default="backtest_results_all/trades.csv")
|
||||||
|
ap.add_argument("--out", default="backtest_results_all/phase1_summary.txt")
|
||||||
|
ap.add_argument("--train-frac", type=float, default=0.7)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
p = Path(args.trades)
|
||||||
|
if not p.exists():
|
||||||
|
print(f"❌ {p} not found")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
df = pd.read_csv(p)
|
||||||
|
df["entry_time"] = pd.to_datetime(df["entry_time"])
|
||||||
|
df["hour"] = df["entry_time"].dt.hour
|
||||||
|
df["dow"] = df["entry_time"].dt.dayofweek
|
||||||
|
df["winner"] = (df["r_multiple"] > 0).astype(int)
|
||||||
|
|
||||||
|
train, test = split(df, args.train_frac)
|
||||||
|
out_lines = []
|
||||||
|
|
||||||
|
def emit(line: str = ""):
|
||||||
|
print(line)
|
||||||
|
out_lines.append(line)
|
||||||
|
|
||||||
|
emit("=" * 92)
|
||||||
|
emit(f"PHASE 1 ANALYSIS — {len(df):,} trades total")
|
||||||
|
emit(f" full: {df['entry_time'].min().date()} → {df['entry_time'].max().date()}")
|
||||||
|
emit(f" train: {train['entry_time'].min().date()} → {train['entry_time'].max().date()} ({len(train):,} trades)")
|
||||||
|
emit(f" test: {test['entry_time'].min().date()} → {test['entry_time'].max().date()} ({len(test):,} trades)")
|
||||||
|
emit("=" * 92)
|
||||||
|
emit()
|
||||||
|
emit(stats_line("BASELINE full", df))
|
||||||
|
emit(stats_line("BASELINE train", train))
|
||||||
|
emit(stats_line("BASELINE test", test))
|
||||||
|
emit()
|
||||||
|
|
||||||
|
# ── Per-hour (training set, used for filter selection) ──────────
|
||||||
|
emit("─" * 92)
|
||||||
|
emit("HOUR (UTC) — train slice (used for filter selection)")
|
||||||
|
emit("─" * 92)
|
||||||
|
hr_stats = train.groupby("hour").apply(
|
||||||
|
lambda x: pd.Series(stats(x)), include_groups=False,
|
||||||
|
).round(3)
|
||||||
|
emit(hr_stats.to_string())
|
||||||
|
emit()
|
||||||
|
|
||||||
|
# Bottom-K hours by avg_r
|
||||||
|
bad_hours_4 = hr_stats.sort_values("avg_r").head(4).index.tolist()
|
||||||
|
bad_hours_6 = hr_stats.sort_values("avg_r").head(6).index.tolist()
|
||||||
|
emit(f" Worst 4 hours by avg_r (train): {sorted(bad_hours_4)}")
|
||||||
|
emit(f" Worst 6 hours by avg_r (train): {sorted(bad_hours_6)}")
|
||||||
|
emit()
|
||||||
|
|
||||||
|
# ── Per-DOW (training set) ──────────────────────────────────────
|
||||||
|
emit("─" * 92)
|
||||||
|
emit("DAY OF WEEK — train slice (0=Mon, 4=Fri)")
|
||||||
|
emit("─" * 92)
|
||||||
|
dow_stats = train.groupby("dow").apply(
|
||||||
|
lambda x: pd.Series(stats(x)), include_groups=False,
|
||||||
|
).round(3)
|
||||||
|
emit(dow_stats.to_string())
|
||||||
|
emit()
|
||||||
|
bad_dow = dow_stats.sort_values("avg_r").head(2).index.tolist()
|
||||||
|
emit(f" Worst 2 days by avg_r (train): {sorted(bad_dow)}")
|
||||||
|
emit()
|
||||||
|
|
||||||
|
# ── Per-pair (training set) ─────────────────────────────────────
|
||||||
|
emit("─" * 92)
|
||||||
|
emit("PAIR — train slice")
|
||||||
|
emit("─" * 92)
|
||||||
|
pair_stats = train.groupby("pair").apply(
|
||||||
|
lambda x: pd.Series(stats(x)), include_groups=False,
|
||||||
|
).round(3)
|
||||||
|
emit(pair_stats.to_string())
|
||||||
|
emit()
|
||||||
|
bad_pairs = pair_stats.sort_values("total_r").head(3).index.tolist()
|
||||||
|
emit(f" Worst 3 pairs by total_r (train): {bad_pairs}")
|
||||||
|
emit()
|
||||||
|
|
||||||
|
# ── Per-regime × direction (training set) ───────────────────────
|
||||||
|
emit("─" * 92)
|
||||||
|
emit("REGIME × DIRECTION — train slice")
|
||||||
|
emit("─" * 92)
|
||||||
|
reg_stats = train.groupby(["regime", "direction"]).apply(
|
||||||
|
lambda x: pd.Series(stats(x)), include_groups=False,
|
||||||
|
).round(3)
|
||||||
|
emit(reg_stats.to_string())
|
||||||
|
emit()
|
||||||
|
|
||||||
|
# ── Filter sweep ON THE TEST SET (rule selected on train) ───────
|
||||||
|
emit("=" * 92)
|
||||||
|
emit("FILTER SWEEP — applied to TEST slice (rule chosen from train, no leakage)")
|
||||||
|
emit("=" * 92)
|
||||||
|
test_filtered_dropworst4hours = test[~test["hour"].isin(bad_hours_4)]
|
||||||
|
test_filtered_dropworst6hours = test[~test["hour"].isin(bad_hours_6)]
|
||||||
|
test_filtered_droppairs = test[~test["pair"].isin(bad_pairs)]
|
||||||
|
test_filtered_droprangingsell = test[~((test["regime"] == "ranging") & (test["direction"] == "SELL"))]
|
||||||
|
test_filtered_only_ranging = test[test["regime"] == "ranging"]
|
||||||
|
test_filtered_drop_dow = test[~test["dow"].isin(bad_dow)]
|
||||||
|
|
||||||
|
test_combo_hr_pair = test[
|
||||||
|
(~test["hour"].isin(bad_hours_4)) & (~test["pair"].isin(bad_pairs))
|
||||||
|
]
|
||||||
|
test_combo_hr_pair_dow = test[
|
||||||
|
(~test["hour"].isin(bad_hours_4))
|
||||||
|
& (~test["pair"].isin(bad_pairs))
|
||||||
|
& (~test["dow"].isin(bad_dow))
|
||||||
|
]
|
||||||
|
|
||||||
|
emit()
|
||||||
|
emit(stats_line("test baseline (no filter)", test))
|
||||||
|
emit(stats_line("test drop 4 worst hours", test_filtered_dropworst4hours))
|
||||||
|
emit(stats_line("test drop 6 worst hours", test_filtered_dropworst6hours))
|
||||||
|
emit(stats_line("test drop 3 worst pairs", test_filtered_droppairs))
|
||||||
|
emit(stats_line("test drop 2 worst DOWs", test_filtered_drop_dow))
|
||||||
|
emit(stats_line("test ONLY ranging regime", test_filtered_only_ranging))
|
||||||
|
emit(stats_line("test hours+pairs (combined)", test_combo_hr_pair))
|
||||||
|
emit(stats_line("test hours+pairs+DOW (combined)", test_combo_hr_pair_dow))
|
||||||
|
emit()
|
||||||
|
|
||||||
|
# ── Same filters applied to TRAIN slice for reference ───────────
|
||||||
|
emit("─" * 92)
|
||||||
|
emit("Same filters — TRAIN slice for reference (these are the in-sample numbers)")
|
||||||
|
emit("─" * 92)
|
||||||
|
train_drop_hours = train[~train["hour"].isin(bad_hours_4)]
|
||||||
|
train_drop_pairs = train[~train["pair"].isin(bad_pairs)]
|
||||||
|
train_combo = train[(~train["hour"].isin(bad_hours_4)) & (~train["pair"].isin(bad_pairs))]
|
||||||
|
|
||||||
|
emit(stats_line("train baseline (no filter)", train))
|
||||||
|
emit(stats_line("train drop 4 worst hours", train_drop_hours))
|
||||||
|
emit(stats_line("train drop 3 worst pairs", train_drop_pairs))
|
||||||
|
emit(stats_line("train hours+pairs (combined)", train_combo))
|
||||||
|
emit()
|
||||||
|
|
||||||
|
# ── Verdict ─────────────────────────────────────────────────────
|
||||||
|
emit("=" * 92)
|
||||||
|
emit("VERDICT")
|
||||||
|
emit("=" * 92)
|
||||||
|
test_base = stats(test)
|
||||||
|
test_combo = stats(test_combo_hr_pair_dow)
|
||||||
|
delta_r = test_combo["total_r"] - test_base["total_r"]
|
||||||
|
delta_avgr = test_combo["avg_r"] - test_base["avg_r"]
|
||||||
|
emit(f" Best combined filter (hours+pairs+DOW) on TEST set:")
|
||||||
|
emit(f" {test_base['n']} → {test_combo['n']} trades "
|
||||||
|
f"({test_combo['n']/test_base['n']*100:.0f}% retained)")
|
||||||
|
emit(f" total R: {test_base['total_r']:+.2f} → {test_combo['total_r']:+.2f} "
|
||||||
|
f"(Δ {delta_r:+.2f})")
|
||||||
|
emit(f" avg R: {test_base['avg_r']:+.3f} → {test_combo['avg_r']:+.3f} "
|
||||||
|
f"(Δ {delta_avgr:+.3f})")
|
||||||
|
emit(f" win rate: {test_base['wr']:.1f}% → {test_combo['wr']:.1f}%")
|
||||||
|
emit()
|
||||||
|
if test_combo["total_r"] > 0:
|
||||||
|
emit(" ✅ Filtered subset is profitable on the held-out test set.")
|
||||||
|
emit(" → Phase 2 (geometry grid) may be unnecessary; this filter alone may suffice.")
|
||||||
|
elif test_combo["avg_r"] > test_base["avg_r"]:
|
||||||
|
emit(" ⚠️ Filtered subset improves but is still negative.")
|
||||||
|
emit(" → Phase 2 (geometry grid) needed to find profitable structure.")
|
||||||
|
else:
|
||||||
|
emit(" ❌ Filters do not survive the train/test split — rejected as noise.")
|
||||||
|
emit(" → Skip Phase 1's filter list; rely on Phase 2 (geometry) instead.")
|
||||||
|
|
||||||
|
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
Path(args.out).write_text("\n".join(out_lines))
|
||||||
|
emit()
|
||||||
|
emit(f"📁 Saved: {args.out}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+712
@@ -0,0 +1,712 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Walk-forward backtest harness for the Forex signal engine.
|
||||||
|
|
||||||
|
Replays historical OHLCV (loaded from history/<PAIR>_<TF>.csv) through
|
||||||
|
the existing SignalAggregator with strict no-look-ahead semantics: at
|
||||||
|
each primary-TF bar close, the engine sees only bars that have *fully
|
||||||
|
closed by that moment* — never the bar still in formation.
|
||||||
|
|
||||||
|
Trade simulation: first-touch (SL vs TP1) on the primary timeframe,
|
||||||
|
worst-case if both touched in the same bar (assume SL first), with
|
||||||
|
per-pip spread cost deducted from the final R-multiple.
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
backtest_results/trades.csv — every simulated trade
|
||||||
|
backtest_results/summary.txt — per-grade win rate / avg R / PF
|
||||||
|
backtest_results/equity.png — cumulative R curve (if --plot)
|
||||||
|
backtest_results/by_grade.png — win rate by grade (if --plot)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python backtest.py --primary M15 --pairs EURUSD
|
||||||
|
python backtest.py --primary H1 --pairs EURUSD GBPUSD USDJPY --plot
|
||||||
|
python backtest.py --primary M15 --grades A+ A
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from config import PlatformConfig, SignalStrength, Timeframe, MAJOR_PAIRS
|
||||||
|
from data_provider import DataUnavailableError, ForexDataProvider
|
||||||
|
from signal_aggregator import SignalAggregator, SignalReport
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
HISTORY_DIR = Path("history")
|
||||||
|
RESULTS_DIR = Path("backtest_results")
|
||||||
|
|
||||||
|
_TF_MAP = {
|
||||||
|
"M1": Timeframe.M1, "M5": Timeframe.M5,
|
||||||
|
"M15": Timeframe.M15, "M30": Timeframe.M30,
|
||||||
|
"H1": Timeframe.H1, "H4": Timeframe.H4,
|
||||||
|
"D1": Timeframe.D1, "W1": Timeframe.W1,
|
||||||
|
}
|
||||||
|
|
||||||
|
_TF_INTERVAL = {
|
||||||
|
Timeframe.M1: pd.Timedelta(minutes=1),
|
||||||
|
Timeframe.M5: pd.Timedelta(minutes=5),
|
||||||
|
Timeframe.M15: pd.Timedelta(minutes=15),
|
||||||
|
Timeframe.M30: pd.Timedelta(minutes=30),
|
||||||
|
Timeframe.H1: pd.Timedelta(hours=1),
|
||||||
|
Timeframe.H4: pd.Timedelta(hours=4),
|
||||||
|
Timeframe.D1: pd.Timedelta(days=1),
|
||||||
|
Timeframe.W1: pd.Timedelta(weeks=1),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Confirmation TFs per primary — same shape Telegram bot uses
|
||||||
|
_TF_CONFIRMATIONS: Dict[Timeframe, List[Timeframe]] = {
|
||||||
|
Timeframe.M15: [Timeframe.H1, Timeframe.D1],
|
||||||
|
Timeframe.M30: [Timeframe.H1, Timeframe.D1],
|
||||||
|
Timeframe.H1: [Timeframe.M15, Timeframe.D1],
|
||||||
|
Timeframe.H4: [Timeframe.H1, Timeframe.D1],
|
||||||
|
Timeframe.D1: [Timeframe.H4],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# Stub news engine (no historical news data — returns neutral)
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class _StubFundamentalContext:
|
||||||
|
"""Placeholder — backtest doesn't replay news, so context is empty."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class StubNewsEngine:
|
||||||
|
"""
|
||||||
|
Drop-in for `NewsEngine` in backtest mode. Returns neutral sentiment
|
||||||
|
and 'clear' event risk for every call. The signal aggregator's news
|
||||||
|
adjustments thus become no-ops, isolating the test to pure technicals.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_fundamental_context(self):
|
||||||
|
return _StubFundamentalContext()
|
||||||
|
|
||||||
|
def get_event_risk_level(self, symbol, context):
|
||||||
|
return ("clear", "")
|
||||||
|
|
||||||
|
def get_pair_sentiment(self, symbol, context):
|
||||||
|
return (0.0, "")
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# Backtest data provider (slices history at "as of" timestamp)
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class BacktestProvider(ForexDataProvider):
|
||||||
|
"""
|
||||||
|
Replaces network fetches with in-memory slices of preloaded OHLCV.
|
||||||
|
|
||||||
|
`set_time(ts)` sets the cut-off; `fetch_ohlcv` returns only bars whose
|
||||||
|
*close* time is ≤ ts. Bars that are still in formation (open ≤ ts but
|
||||||
|
close > ts) are excluded — this is the no-look-ahead guarantee.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: PlatformConfig,
|
||||||
|
history: Dict[Tuple[str, Timeframe], pd.DataFrame],
|
||||||
|
):
|
||||||
|
super().__init__(config)
|
||||||
|
self._history = history
|
||||||
|
self._current_ts: Optional[pd.Timestamp] = None
|
||||||
|
|
||||||
|
def set_time(self, ts: pd.Timestamp) -> None:
|
||||||
|
if ts.tzinfo is None:
|
||||||
|
ts = ts.tz_localize("UTC")
|
||||||
|
self._current_ts = ts
|
||||||
|
|
||||||
|
def fetch_ohlcv(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
timeframe: Timeframe,
|
||||||
|
lookback_days: Optional[int] = None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
df = self._history.get((symbol, timeframe))
|
||||||
|
if df is None:
|
||||||
|
raise DataUnavailableError(
|
||||||
|
f"No history loaded for {symbol} {timeframe.value}. "
|
||||||
|
f"Run seed_history.py with --tfs including this TF."
|
||||||
|
)
|
||||||
|
if self._current_ts is not None:
|
||||||
|
interval = _TF_INTERVAL[timeframe]
|
||||||
|
df = df[df.index + interval <= self._current_ts]
|
||||||
|
if df.empty:
|
||||||
|
raise DataUnavailableError(
|
||||||
|
f"No bars before {self._current_ts} for {symbol} {timeframe.value}"
|
||||||
|
)
|
||||||
|
return df
|
||||||
|
|
||||||
|
def prefetch_batch(self, symbols, timeframes, lookback_days=None):
|
||||||
|
return # no-op: data is in memory
|
||||||
|
|
||||||
|
def get_spread_estimate(self, symbol: str) -> float:
|
||||||
|
# Backtest doesn't need a live spread estimate; return a fixed value
|
||||||
|
# so the aggregator's spread field is filled but does not affect SL/TP.
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# History loader
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def load_history(
|
||||||
|
pairs: List[str],
|
||||||
|
timeframes: List[Timeframe],
|
||||||
|
history_dir: Path = HISTORY_DIR,
|
||||||
|
) -> Dict[Tuple[str, Timeframe], pd.DataFrame]:
|
||||||
|
"""
|
||||||
|
Load CSVs produced by seed_history.py into a dict keyed by (symbol, tf).
|
||||||
|
|
||||||
|
Drops CSVs that are missing — caller decides whether to abort or continue
|
||||||
|
with reduced TF coverage.
|
||||||
|
"""
|
||||||
|
history: Dict[Tuple[str, Timeframe], pd.DataFrame] = {}
|
||||||
|
missing: List[str] = []
|
||||||
|
|
||||||
|
for symbol in pairs:
|
||||||
|
for tf in timeframes:
|
||||||
|
csv_p = history_dir / f"{symbol.replace('=X', '')}_{tf.value}.csv"
|
||||||
|
if not csv_p.exists():
|
||||||
|
missing.append(str(csv_p))
|
||||||
|
continue
|
||||||
|
df = pd.read_csv(csv_p, index_col="Datetime", parse_dates=["Datetime"])
|
||||||
|
if df.index.tz is None:
|
||||||
|
df.index = df.index.tz_localize("UTC")
|
||||||
|
history[(symbol, tf)] = df.sort_index()
|
||||||
|
logger.info(
|
||||||
|
f"loaded {symbol} {tf.value}: {len(df):,} bars "
|
||||||
|
f"({df.index.min().date()} → {df.index.max().date()})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
logger.warning(f"missing CSVs ({len(missing)}): {missing[:5]}…")
|
||||||
|
|
||||||
|
return history
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# Trade record + first-touch simulator
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Trade:
|
||||||
|
symbol: str
|
||||||
|
pair_display: str
|
||||||
|
primary_tf: str
|
||||||
|
grade: str
|
||||||
|
direction: str
|
||||||
|
entry_time: pd.Timestamp
|
||||||
|
entry_price: float
|
||||||
|
sl: float
|
||||||
|
tp1: float
|
||||||
|
tp2: float
|
||||||
|
risk_pips: float
|
||||||
|
reward_pips: float
|
||||||
|
score: float
|
||||||
|
confidence: float
|
||||||
|
regime: str
|
||||||
|
mtf_aligned: bool
|
||||||
|
|
||||||
|
# Filled by simulation
|
||||||
|
exit_time: Optional[pd.Timestamp] = None
|
||||||
|
exit_price: float = 0.0
|
||||||
|
outcome: str = "OPEN" # WIN_TP1, LOSS_SL, TIMEOUT, INVALID
|
||||||
|
bars_held: int = 0
|
||||||
|
r_multiple: float = 0.0 # net R after spread cost
|
||||||
|
|
||||||
|
|
||||||
|
def simulate_trade(
|
||||||
|
trade: Trade,
|
||||||
|
forward_bars: pd.DataFrame,
|
||||||
|
max_bars: int,
|
||||||
|
spread_pips: float,
|
||||||
|
) -> Trade:
|
||||||
|
"""
|
||||||
|
First-touch simulation on the primary timeframe.
|
||||||
|
|
||||||
|
Conservatives:
|
||||||
|
- If both SL and TP1 touch within the same bar, assume SL first.
|
||||||
|
- Spread cost (entry + exit round-trip) is deducted from final R.
|
||||||
|
- TP2 is recorded but not used in this baseline simulation — we cap at
|
||||||
|
TP1 to keep the R-multiple math clean. Extend with breakeven-trail
|
||||||
|
management later if needed.
|
||||||
|
"""
|
||||||
|
if trade.risk_pips <= 0:
|
||||||
|
trade.outcome = "INVALID"
|
||||||
|
return trade
|
||||||
|
|
||||||
|
spread_r = spread_pips / trade.risk_pips
|
||||||
|
bars_examined = 0
|
||||||
|
|
||||||
|
for ts, bar in forward_bars.iterrows():
|
||||||
|
bars_examined += 1
|
||||||
|
if bars_examined > max_bars:
|
||||||
|
break
|
||||||
|
|
||||||
|
bar_high = bar["High"]
|
||||||
|
bar_low = bar["Low"]
|
||||||
|
|
||||||
|
if trade.direction == "BUY":
|
||||||
|
sl_hit = bar_low <= trade.sl
|
||||||
|
tp_hit = bar_high >= trade.tp1
|
||||||
|
else:
|
||||||
|
sl_hit = bar_high >= trade.sl
|
||||||
|
tp_hit = bar_low <= trade.tp1
|
||||||
|
|
||||||
|
if sl_hit:
|
||||||
|
trade.outcome = "LOSS_SL"
|
||||||
|
trade.exit_time = ts
|
||||||
|
trade.exit_price = trade.sl
|
||||||
|
trade.bars_held = bars_examined
|
||||||
|
trade.r_multiple = -1.0 - spread_r
|
||||||
|
return trade
|
||||||
|
|
||||||
|
if tp_hit:
|
||||||
|
trade.outcome = "WIN_TP1"
|
||||||
|
trade.exit_time = ts
|
||||||
|
trade.exit_price = trade.tp1
|
||||||
|
trade.bars_held = bars_examined
|
||||||
|
# TP1 sits at risk × RR away from entry → R = reward_pips / risk_pips
|
||||||
|
trade.r_multiple = (trade.reward_pips / trade.risk_pips) - spread_r
|
||||||
|
return trade
|
||||||
|
|
||||||
|
# Timeout — exit at last seen close
|
||||||
|
if bars_examined == 0:
|
||||||
|
trade.outcome = "INVALID"
|
||||||
|
return trade
|
||||||
|
|
||||||
|
last_idx = bars_examined - 1
|
||||||
|
last = forward_bars.iloc[last_idx]
|
||||||
|
last_ts = forward_bars.index[last_idx]
|
||||||
|
|
||||||
|
if trade.direction == "BUY":
|
||||||
|
pnl_price = last["Close"] - trade.entry_price
|
||||||
|
else:
|
||||||
|
pnl_price = trade.entry_price - last["Close"]
|
||||||
|
|
||||||
|
sl_distance_price = abs(trade.entry_price - trade.sl)
|
||||||
|
pnl_r = pnl_price / sl_distance_price if sl_distance_price > 0 else 0.0
|
||||||
|
|
||||||
|
trade.outcome = "TIMEOUT"
|
||||||
|
trade.exit_time = last_ts
|
||||||
|
trade.exit_price = float(last["Close"])
|
||||||
|
trade.bars_held = bars_examined
|
||||||
|
trade.r_multiple = pnl_r - spread_r
|
||||||
|
return trade
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# Backtester (the replay loop)
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class Backtester:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
primary_tf: Timeframe,
|
||||||
|
pairs: List[str],
|
||||||
|
history: Dict[Tuple[str, Timeframe], pd.DataFrame],
|
||||||
|
target_grades: List[str],
|
||||||
|
max_hold_bars: int = 100,
|
||||||
|
spread_pips: float = 1.0,
|
||||||
|
warmup_bars: int = 250,
|
||||||
|
):
|
||||||
|
self.primary_tf = primary_tf
|
||||||
|
self.pairs = pairs
|
||||||
|
self.history = history
|
||||||
|
self.target_grades = set(target_grades)
|
||||||
|
self.max_hold_bars = max_hold_bars
|
||||||
|
self.spread_pips = spread_pips
|
||||||
|
self.warmup_bars = warmup_bars
|
||||||
|
self.confirmation_tfs = _TF_CONFIRMATIONS.get(primary_tf, [])
|
||||||
|
|
||||||
|
def _build_config(self, symbol: str) -> PlatformConfig:
|
||||||
|
cfg = PlatformConfig()
|
||||||
|
cfg.primary_timeframe = self.primary_tf
|
||||||
|
cfg.confirmation_timeframes = list(self.confirmation_tfs)
|
||||||
|
cfg.watch_pairs = [symbol]
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
def _make_aggregator(self, config: PlatformConfig, provider: BacktestProvider) -> SignalAggregator:
|
||||||
|
agg = SignalAggregator(config, data_provider=provider)
|
||||||
|
# Replace live news engine with neutral stub (no historical news data)
|
||||||
|
agg.news_engine = StubNewsEngine()
|
||||||
|
return agg
|
||||||
|
|
||||||
|
def run(self) -> List[Trade]:
|
||||||
|
trades: List[Trade] = []
|
||||||
|
provider = BacktestProvider(PlatformConfig(), self.history)
|
||||||
|
|
||||||
|
for symbol in self.pairs:
|
||||||
|
primary_df = self.history.get((symbol, self.primary_tf))
|
||||||
|
if primary_df is None or primary_df.empty:
|
||||||
|
logger.warning(f"{symbol}: no primary {self.primary_tf.value} data, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
config = self._build_config(symbol)
|
||||||
|
aggregator = self._make_aggregator(config, provider)
|
||||||
|
|
||||||
|
interval = _TF_INTERVAL[self.primary_tf]
|
||||||
|
timestamps = primary_df.index
|
||||||
|
n_total = len(timestamps)
|
||||||
|
|
||||||
|
print(f"\n📊 {symbol} {self.primary_tf.value}: "
|
||||||
|
f"{n_total:,} bars, warmup={self.warmup_bars}")
|
||||||
|
|
||||||
|
open_until: Optional[pd.Timestamp] = None # block overlapping entries
|
||||||
|
n_signals = 0
|
||||||
|
|
||||||
|
for i, bar_open_ts in enumerate(timestamps):
|
||||||
|
if i < self.warmup_bars:
|
||||||
|
continue
|
||||||
|
if open_until is not None and bar_open_ts < open_until:
|
||||||
|
continue # previous trade still open — skip
|
||||||
|
|
||||||
|
# Run engine "as if it's now this bar's close"
|
||||||
|
bar_close_ts = bar_open_ts + interval
|
||||||
|
provider.set_time(bar_close_ts)
|
||||||
|
|
||||||
|
try:
|
||||||
|
report = aggregator.analyze_pair(symbol)
|
||||||
|
except DataUnavailableError:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"engine error at {bar_open_ts}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if report.signal_quality not in self.target_grades:
|
||||||
|
continue
|
||||||
|
if report.trade_setup is None:
|
||||||
|
continue
|
||||||
|
if report.final_signal == SignalStrength.NEUTRAL:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ts = report.trade_setup
|
||||||
|
trade = Trade(
|
||||||
|
symbol = symbol,
|
||||||
|
pair_display = report.display_name,
|
||||||
|
primary_tf = self.primary_tf.value,
|
||||||
|
grade = report.signal_quality,
|
||||||
|
direction = ts.direction,
|
||||||
|
entry_time = bar_open_ts,
|
||||||
|
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 = report.final_score,
|
||||||
|
confidence = report.final_confidence,
|
||||||
|
regime = report.market_regime,
|
||||||
|
mtf_aligned = report.mtf_agreement,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Forward window for simulation
|
||||||
|
forward = primary_df.iloc[i + 1 : i + 1 + self.max_hold_bars]
|
||||||
|
if forward.empty:
|
||||||
|
continue
|
||||||
|
|
||||||
|
trade = simulate_trade(
|
||||||
|
trade,
|
||||||
|
forward,
|
||||||
|
max_bars=self.max_hold_bars,
|
||||||
|
spread_pips=self.spread_pips,
|
||||||
|
)
|
||||||
|
trades.append(trade)
|
||||||
|
n_signals += 1
|
||||||
|
|
||||||
|
if trade.exit_time is not None:
|
||||||
|
open_until = trade.exit_time
|
||||||
|
|
||||||
|
if n_signals % 25 == 0:
|
||||||
|
print(f" [{i:>6}/{n_total}] {n_signals} trades so far "
|
||||||
|
f"(latest: {trade.grade} {trade.direction} {trade.outcome} "
|
||||||
|
f"R={trade.r_multiple:+.2f})")
|
||||||
|
|
||||||
|
print(f" ✅ {symbol}: {n_signals} trades")
|
||||||
|
|
||||||
|
return trades
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# Reporting
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def trades_to_dataframe(trades: List[Trade]) -> pd.DataFrame:
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"entry_time": t.entry_time,
|
||||||
|
"exit_time": t.exit_time,
|
||||||
|
"symbol": t.symbol,
|
||||||
|
"pair": t.pair_display,
|
||||||
|
"tf": t.primary_tf,
|
||||||
|
"grade": t.grade,
|
||||||
|
"direction": t.direction,
|
||||||
|
"entry": t.entry_price,
|
||||||
|
"sl": t.sl,
|
||||||
|
"tp1": t.tp1,
|
||||||
|
"exit_price": t.exit_price,
|
||||||
|
"outcome": t.outcome,
|
||||||
|
"bars_held": t.bars_held,
|
||||||
|
"r_multiple": t.r_multiple,
|
||||||
|
"score": t.score,
|
||||||
|
"confidence": t.confidence,
|
||||||
|
"regime": t.regime,
|
||||||
|
"mtf_aligned": t.mtf_aligned,
|
||||||
|
"risk_pips": t.risk_pips,
|
||||||
|
"reward_pips": t.reward_pips,
|
||||||
|
}
|
||||||
|
for t in trades
|
||||||
|
]
|
||||||
|
return pd.DataFrame(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def summarize(df: pd.DataFrame) -> str:
|
||||||
|
"""Per-grade win rate / average R / profit factor summary."""
|
||||||
|
if df.empty:
|
||||||
|
return "No trades recorded."
|
||||||
|
|
||||||
|
lines: List[str] = []
|
||||||
|
lines.append("=" * 78)
|
||||||
|
lines.append(f"BACKTEST SUMMARY ({len(df)} trades)")
|
||||||
|
lines.append(f" Date range: {df['entry_time'].min()} → {df['entry_time'].max()}")
|
||||||
|
lines.append("=" * 78)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
overall = _grade_block("ALL", df)
|
||||||
|
lines.append(overall)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
for grade in ["A+", "A", "B", "C", "D"]:
|
||||||
|
sub = df[df["grade"] == grade]
|
||||||
|
if sub.empty:
|
||||||
|
continue
|
||||||
|
lines.append(_grade_block(grade, sub))
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Per-pair breakdown
|
||||||
|
lines.append("─" * 78)
|
||||||
|
lines.append("PER-PAIR SUMMARY")
|
||||||
|
lines.append("─" * 78)
|
||||||
|
by_pair = df.groupby("pair").agg(
|
||||||
|
trades=("r_multiple", "count"),
|
||||||
|
win_rate=("r_multiple", lambda s: (s > 0).mean() * 100),
|
||||||
|
avg_r=("r_multiple", "mean"),
|
||||||
|
total_r=("r_multiple", "sum"),
|
||||||
|
).round(2)
|
||||||
|
lines.append(by_pair.to_string())
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Per-direction
|
||||||
|
lines.append("─" * 78)
|
||||||
|
lines.append("BY DIRECTION")
|
||||||
|
lines.append("─" * 78)
|
||||||
|
by_dir = df.groupby("direction").agg(
|
||||||
|
trades=("r_multiple", "count"),
|
||||||
|
win_rate=("r_multiple", lambda s: (s > 0).mean() * 100),
|
||||||
|
avg_r=("r_multiple", "mean"),
|
||||||
|
).round(2)
|
||||||
|
lines.append(by_dir.to_string())
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _grade_block(label: str, sub: pd.DataFrame) -> str:
|
||||||
|
n = len(sub)
|
||||||
|
wins = (sub["r_multiple"] > 0).sum()
|
||||||
|
losses = (sub["r_multiple"] <= 0).sum()
|
||||||
|
win_rate = wins / n * 100 if n else 0.0
|
||||||
|
avg_r = sub["r_multiple"].mean()
|
||||||
|
sum_r = sub["r_multiple"].sum()
|
||||||
|
|
||||||
|
gross_win = sub.loc[sub["r_multiple"] > 0, "r_multiple"].sum()
|
||||||
|
gross_loss = -sub.loc[sub["r_multiple"] <= 0, "r_multiple"].sum()
|
||||||
|
profit_factor = (gross_win / gross_loss) if gross_loss > 0 else float("inf")
|
||||||
|
|
||||||
|
# Equity-curve drawdown (in R)
|
||||||
|
cum = sub["r_multiple"].cumsum()
|
||||||
|
running_peak = cum.cummax()
|
||||||
|
drawdown = (cum - running_peak).min()
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"{label:>4}: {n:>4} trades | "
|
||||||
|
f"win {win_rate:5.1f}% ({wins}W/{losses}L) | "
|
||||||
|
f"avg R {avg_r:+.3f} | total R {sum_r:+.2f} | "
|
||||||
|
f"PF {profit_factor:.2f} | maxDD {drawdown:.2f}R"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def plot_results(df: pd.DataFrame, out_dir: Path) -> None:
|
||||||
|
"""Generate equity curve and per-grade bar chart. Requires matplotlib."""
|
||||||
|
try:
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use("Agg") # headless
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
except ImportError:
|
||||||
|
print("⚠️ matplotlib not installed — skipping plots. "
|
||||||
|
"pip install matplotlib")
|
||||||
|
return
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
return
|
||||||
|
|
||||||
|
df = df.sort_values("entry_time").reset_index(drop=True)
|
||||||
|
df["cum_r"] = df["r_multiple"].cumsum()
|
||||||
|
|
||||||
|
# Equity curve
|
||||||
|
fig, ax = plt.subplots(figsize=(11, 5))
|
||||||
|
ax.plot(df["entry_time"], df["cum_r"], linewidth=1.5)
|
||||||
|
ax.axhline(0, color="gray", linewidth=0.5)
|
||||||
|
ax.fill_between(df["entry_time"], df["cum_r"], 0,
|
||||||
|
where=df["cum_r"] >= 0, alpha=0.2, color="green")
|
||||||
|
ax.fill_between(df["entry_time"], df["cum_r"], 0,
|
||||||
|
where=df["cum_r"] < 0, alpha=0.2, color="red")
|
||||||
|
ax.set_title(f"Cumulative R ({len(df)} trades, total {df['cum_r'].iloc[-1]:+.2f}R)")
|
||||||
|
ax.set_xlabel("Trade entry time")
|
||||||
|
ax.set_ylabel("Cumulative R (1R = risked amount per trade)")
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
fig.tight_layout()
|
||||||
|
fig.savefig(out_dir / "equity.png", dpi=120)
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
# Win rate by grade
|
||||||
|
grades = ["A+", "A", "B", "C", "D"]
|
||||||
|
grade_stats = []
|
||||||
|
for g in grades:
|
||||||
|
sub = df[df["grade"] == g]
|
||||||
|
if sub.empty:
|
||||||
|
continue
|
||||||
|
wr = (sub["r_multiple"] > 0).mean() * 100
|
||||||
|
avg_r = sub["r_multiple"].mean()
|
||||||
|
grade_stats.append((g, len(sub), wr, avg_r))
|
||||||
|
|
||||||
|
if grade_stats:
|
||||||
|
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4.5))
|
||||||
|
labels = [f"{g}\n(n={n})" for g, n, _, _ in grade_stats]
|
||||||
|
wrs = [w for _, _, w, _ in grade_stats]
|
||||||
|
avgs = [r for _, _, _, r in grade_stats]
|
||||||
|
|
||||||
|
ax1.bar(labels, wrs, color=["#16a34a", "#22c55e", "#84cc16", "#eab308", "#f97316"][:len(labels)])
|
||||||
|
ax1.axhline(50, linestyle="--", color="gray", linewidth=0.8)
|
||||||
|
ax1.set_ylabel("Win rate (%)")
|
||||||
|
ax1.set_title("Win rate by grade")
|
||||||
|
for i, w in enumerate(wrs):
|
||||||
|
ax1.text(i, w + 1, f"{w:.0f}%", ha="center", fontsize=9)
|
||||||
|
|
||||||
|
ax2.bar(labels, avgs, color=["#16a34a", "#22c55e", "#84cc16", "#eab308", "#f97316"][:len(labels)])
|
||||||
|
ax2.axhline(0, color="black", linewidth=0.5)
|
||||||
|
ax2.set_ylabel("Average R per trade")
|
||||||
|
ax2.set_title("Average R by grade")
|
||||||
|
for i, a in enumerate(avgs):
|
||||||
|
ax2.text(i, a + (0.02 if a >= 0 else -0.05), f"{a:+.2f}",
|
||||||
|
ha="center", fontsize=9)
|
||||||
|
|
||||||
|
fig.tight_layout()
|
||||||
|
fig.savefig(out_dir / "by_grade.png", dpi=120)
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# CLI
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Walk-forward backtest of the Forex signal engine.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--primary", default="M15",
|
||||||
|
help="Primary timeframe (default M15)")
|
||||||
|
parser.add_argument("--pairs", nargs="+", default=None,
|
||||||
|
help="Pairs to test (default: all majors)")
|
||||||
|
parser.add_argument("--grades", nargs="+", default=["A+", "A"],
|
||||||
|
help='Grades to take (default "A+ A")')
|
||||||
|
parser.add_argument("--history-dir", default=str(HISTORY_DIR))
|
||||||
|
parser.add_argument("--out-dir", default=str(RESULTS_DIR))
|
||||||
|
parser.add_argument("--max-hold-bars", type=int, default=100,
|
||||||
|
help="Max bars to hold a trade before timeout (default 100)")
|
||||||
|
parser.add_argument("--spread-pips", type=float, default=1.0,
|
||||||
|
help="Round-trip spread cost in pips (default 1.0)")
|
||||||
|
parser.add_argument("--warmup-bars", type=int, default=250,
|
||||||
|
help="Bars to skip at start so indicators stabilize (default 250)")
|
||||||
|
parser.add_argument("--plot", action="store_true",
|
||||||
|
help="Generate equity / by-grade PNGs (needs matplotlib)")
|
||||||
|
parser.add_argument("-v", "--verbose", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO if args.verbose else logging.WARNING,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
primary_tf = _TF_MAP[args.primary.upper()]
|
||||||
|
confirmations = _TF_CONFIRMATIONS.get(primary_tf, [])
|
||||||
|
needed_tfs = [primary_tf] + confirmations
|
||||||
|
|
||||||
|
pairs = args.pairs if args.pairs else MAJOR_PAIRS
|
||||||
|
pairs = [p if p.endswith("=X") else f"{p}=X" for p in pairs]
|
||||||
|
|
||||||
|
history_dir = Path(args.history_dir)
|
||||||
|
out_dir = Path(args.out_dir)
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
print(f"🔬 Backtest: primary={primary_tf.value} "
|
||||||
|
f"confirmations={[t.value for t in confirmations]}")
|
||||||
|
print(f" Pairs: {[p.replace('=X','') for p in pairs]}")
|
||||||
|
print(f" Grades: {args.grades}")
|
||||||
|
print(f" History dir: {history_dir}/")
|
||||||
|
print(f" Results dir: {out_dir}/")
|
||||||
|
print()
|
||||||
|
|
||||||
|
history = load_history(pairs, needed_tfs, history_dir=history_dir)
|
||||||
|
if not history:
|
||||||
|
print(f"❌ No history loaded from {history_dir}/. "
|
||||||
|
f"Run: python seed_history.py --tfs {' '.join(t.value for t in needed_tfs)}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
bt = Backtester(
|
||||||
|
primary_tf = primary_tf,
|
||||||
|
pairs = pairs,
|
||||||
|
history = history,
|
||||||
|
target_grades = args.grades,
|
||||||
|
max_hold_bars = args.max_hold_bars,
|
||||||
|
spread_pips = args.spread_pips,
|
||||||
|
warmup_bars = args.warmup_bars,
|
||||||
|
)
|
||||||
|
|
||||||
|
trades = bt.run()
|
||||||
|
|
||||||
|
if not trades:
|
||||||
|
print("\n❌ No trades emitted. Try lowering --grades (e.g., 'B') or "
|
||||||
|
"check that primary+confirmation TFs are in history/.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
df = trades_to_dataframe(trades)
|
||||||
|
df.to_csv(out_dir / "trades.csv", index=False)
|
||||||
|
|
||||||
|
summary = summarize(df)
|
||||||
|
(out_dir / "summary.txt").write_text(summary)
|
||||||
|
|
||||||
|
print("\n" + summary)
|
||||||
|
print(f"\n📁 trades.csv → {out_dir/'trades.csv'}")
|
||||||
|
print(f"📁 summary.txt → {out_dir/'summary.txt'}")
|
||||||
|
|
||||||
|
if args.plot:
|
||||||
|
plot_results(df, out_dir)
|
||||||
|
print(f"📈 equity.png → {out_dir/'equity.png'}")
|
||||||
|
print(f"📈 by_grade.png → {out_dir/'by_grade.png'}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -456,6 +456,125 @@ class ForexDataProvider:
|
|||||||
f"Failed to fetch {td_symbol}: {e}"
|
f"Failed to fetch {td_symbol}: {e}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
def fetch_historical(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
timeframe: Timeframe,
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Paginated historical OHLCV fetch for backtesting seed.
|
||||||
|
|
||||||
|
Walks backward from `end` to `start` in 5000-bar chunks (the free-tier
|
||||||
|
per-call max), 1 credit per chunk. Honours the shared rate limiter so
|
||||||
|
it cooperates with any concurrent live scans.
|
||||||
|
|
||||||
|
Returns a single concatenated DataFrame indexed by UTC timestamp.
|
||||||
|
"""
|
||||||
|
if not self._api_key:
|
||||||
|
raise DataUnavailableError("No Twelve Data API key set.")
|
||||||
|
|
||||||
|
if start.tzinfo is None:
|
||||||
|
start = start.replace(tzinfo=timezone.utc)
|
||||||
|
if end.tzinfo is None:
|
||||||
|
end = end.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
bars_per_day = self.BARS_PER_DAY[timeframe]
|
||||||
|
# Stay well clear of the 5000-bar cap — leave a small margin for
|
||||||
|
# weekend gaps that don't decrement bars on calendar-day arithmetic.
|
||||||
|
chunk_days = max(1, int(4500 / bars_per_day))
|
||||||
|
|
||||||
|
td_symbol = self._to_td_symbol(symbol)
|
||||||
|
interval = self.TD_INTERVALS[timeframe]
|
||||||
|
|
||||||
|
chunks: List[pd.DataFrame] = []
|
||||||
|
cursor_end = end
|
||||||
|
|
||||||
|
while cursor_end > start:
|
||||||
|
cursor_start = max(start, cursor_end - timedelta(days=chunk_days))
|
||||||
|
|
||||||
|
self._throttle(credits=1)
|
||||||
|
|
||||||
|
resp = self._session.get(
|
||||||
|
f"{self.TD_BASE_URL}/time_series",
|
||||||
|
params={
|
||||||
|
"symbol": td_symbol,
|
||||||
|
"interval": interval,
|
||||||
|
"start_date": cursor_start.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"end_date": cursor_end.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"outputsize": 5000,
|
||||||
|
"timezone": "UTC",
|
||||||
|
"format": "json",
|
||||||
|
},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
if isinstance(data, dict) and data.get("status") == "error":
|
||||||
|
msg = data.get("message", "unknown")
|
||||||
|
# If we already have chunks, an error on a deeper-history call
|
||||||
|
# almost always means "you've walked past the available start."
|
||||||
|
# Treat as graceful end-of-history instead of fatal.
|
||||||
|
if chunks:
|
||||||
|
logger.info(
|
||||||
|
f" {td_symbol} {interval}: end of available history "
|
||||||
|
f"reached ({msg})"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
raise DataUnavailableError(
|
||||||
|
f"Twelve Data error for {td_symbol}: {msg}"
|
||||||
|
)
|
||||||
|
|
||||||
|
values = data.get("values") or []
|
||||||
|
if not values:
|
||||||
|
logger.info(
|
||||||
|
f" {td_symbol} {interval}: empty range "
|
||||||
|
f"{cursor_start.date()}..{cursor_end.date()}, stopping"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
records = [
|
||||||
|
{
|
||||||
|
"Datetime": pd.Timestamp(v["datetime"], tz="UTC"),
|
||||||
|
"Open": float(v["open"]),
|
||||||
|
"High": float(v["high"]),
|
||||||
|
"Low": float(v["low"]),
|
||||||
|
"Close": float(v["close"]),
|
||||||
|
"Volume": float(v.get("volume") or 0),
|
||||||
|
}
|
||||||
|
for v in values if v.get("datetime")
|
||||||
|
]
|
||||||
|
df_chunk = pd.DataFrame(records).set_index("Datetime").sort_index()
|
||||||
|
df_chunk.index.name = "Datetime"
|
||||||
|
chunks.append(df_chunk)
|
||||||
|
|
||||||
|
earliest = df_chunk.index.min()
|
||||||
|
logger.info(
|
||||||
|
f" {td_symbol} {interval}: chunk "
|
||||||
|
f"{earliest.date()} → {df_chunk.index.max().date()} "
|
||||||
|
f"({len(df_chunk):,} bars)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stop when:
|
||||||
|
# 1. The current chunk's start window already hit the requested
|
||||||
|
# `start` boundary — nothing earlier was asked for.
|
||||||
|
# 2. The chunk is unusually thin (< 100 bars), suggesting we've
|
||||||
|
# reached the edge of the symbol's available history.
|
||||||
|
if cursor_start <= start or len(df_chunk) < 100:
|
||||||
|
break
|
||||||
|
|
||||||
|
cursor_end = earliest - timedelta(seconds=1)
|
||||||
|
|
||||||
|
if not chunks:
|
||||||
|
raise DataUnavailableError(f"No historical data returned for {td_symbol}")
|
||||||
|
|
||||||
|
df = pd.concat(chunks).sort_index()
|
||||||
|
df = df[~df.index.duplicated(keep="last")]
|
||||||
|
df = df[(df.index >= start) & (df.index <= end)]
|
||||||
|
return df
|
||||||
|
|
||||||
def _validate_and_clean(
|
def _validate_and_clean(
|
||||||
self, df: pd.DataFrame, symbol: str, timeframe: Timeframe
|
self, df: pd.DataFrame, symbol: str, timeframe: Timeframe
|
||||||
) -> pd.DataFrame:
|
) -> pd.DataFrame:
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Phase 2: re-simulate the existing trade pool under different SL/TP geometries
|
||||||
|
without re-running the engine.
|
||||||
|
|
||||||
|
The engine already gave us, per trade: entry_time, entry_price, direction,
|
||||||
|
and the original SL distance (= 1.5 × ATR by default). That distance lets
|
||||||
|
us recover ATR and re-derive new SL/TP for any (sl_mult, rr) combo. We
|
||||||
|
then walk forward through the saved OHLCV bars, applying first-touch
|
||||||
|
simulation with worst-case (SL first if both touch same bar).
|
||||||
|
|
||||||
|
This is faster than re-running the engine because the signal output (when
|
||||||
|
to trade, in which direction, at what price) is independent of SL/TP
|
||||||
|
geometry — only the exits change.
|
||||||
|
|
||||||
|
Discipline: we report TRAIN and TEST stats for every cell, time-split at
|
||||||
|
70/30 by entry_time. A cell wins only if it's positive on the held-out
|
||||||
|
TEST slice.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python geometry_grid.py
|
||||||
|
python geometry_grid.py --apply-phase1-filters
|
||||||
|
python geometry_grid.py --pairs USDJPY # single-pair grid
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
# Reuse history loader so we don't re-implement
|
||||||
|
from backtest import load_history
|
||||||
|
from config import Timeframe
|
||||||
|
|
||||||
|
# trades.csv stores TF as Twelve-Data interval string ("15min", "1h", "1d", …)
|
||||||
|
_TFSTR_TO_ENUM = {
|
||||||
|
"1min": Timeframe.M1, "5min": Timeframe.M5,
|
||||||
|
"15min": Timeframe.M15, "30min": Timeframe.M30,
|
||||||
|
"1h": Timeframe.H1, "4h": Timeframe.H4,
|
||||||
|
"1d": Timeframe.D1, "1wk": Timeframe.W1,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Filters carried over from Phase 1
|
||||||
|
PHASE1_BAD_HOURS = {10, 17, 19, 21}
|
||||||
|
PHASE1_BAD_PAIRS = {"NZD/USD", "AUD/USD", "GBP/USD"}
|
||||||
|
PHASE1_BAD_DOWS = {5, 6} # Sat / Sun
|
||||||
|
|
||||||
|
|
||||||
|
def simulate_one(
|
||||||
|
direction: str,
|
||||||
|
entry: float,
|
||||||
|
new_sl: float,
|
||||||
|
new_tp: float,
|
||||||
|
forward_bars: pd.DataFrame,
|
||||||
|
max_bars: int,
|
||||||
|
spread_pips: float,
|
||||||
|
pip_value: float,
|
||||||
|
) -> Tuple[float, str]:
|
||||||
|
"""First-touch simulation. Returns (r_multiple, outcome)."""
|
||||||
|
sl_distance = abs(entry - new_sl)
|
||||||
|
if sl_distance == 0:
|
||||||
|
return 0.0, "INVALID"
|
||||||
|
risk_pips = sl_distance / pip_value
|
||||||
|
spread_r = spread_pips / risk_pips if risk_pips > 0 else 0.0
|
||||||
|
rr_target = abs(new_tp - entry) / sl_distance
|
||||||
|
|
||||||
|
bars_examined = 0
|
||||||
|
for ts, bar in forward_bars.iterrows():
|
||||||
|
bars_examined += 1
|
||||||
|
if bars_examined > max_bars:
|
||||||
|
break
|
||||||
|
bar_high = bar["High"]
|
||||||
|
bar_low = bar["Low"]
|
||||||
|
if direction == "BUY":
|
||||||
|
sl_hit = bar_low <= new_sl
|
||||||
|
tp_hit = bar_high >= new_tp
|
||||||
|
else:
|
||||||
|
sl_hit = bar_high >= new_sl
|
||||||
|
tp_hit = bar_low <= new_tp
|
||||||
|
if sl_hit:
|
||||||
|
return -1.0 - spread_r, "LOSS_SL"
|
||||||
|
if tp_hit:
|
||||||
|
return rr_target - spread_r, "WIN_TP1"
|
||||||
|
|
||||||
|
if bars_examined == 0:
|
||||||
|
return 0.0, "INVALID"
|
||||||
|
last = forward_bars.iloc[bars_examined - 1]
|
||||||
|
pnl = (last["Close"] - entry) if direction == "BUY" else (entry - last["Close"])
|
||||||
|
return (pnl / sl_distance) - spread_r, "TIMEOUT"
|
||||||
|
|
||||||
|
|
||||||
|
def pip_value_for(symbol: str) -> float:
|
||||||
|
"""Return pip value: 0.01 for JPY pairs, 0.0001 elsewhere."""
|
||||||
|
return 0.01 if "JPY" in symbol else 0.0001
|
||||||
|
|
||||||
|
|
||||||
|
def grid_run(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
history: Dict[Tuple[str, str], pd.DataFrame],
|
||||||
|
sl_mults: list,
|
||||||
|
rr_ratios: list,
|
||||||
|
max_hold: int = 100,
|
||||||
|
spread_pips: float = 1.0,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""For each (sl_mult, rr) cell, re-simulate every trade and report stats."""
|
||||||
|
# Pre-index history by (symbol, tf_str) for fast lookup
|
||||||
|
rows = []
|
||||||
|
n_total = len(df) * len(sl_mults) * len(rr_ratios)
|
||||||
|
print(f" evaluating {n_total:,} simulations "
|
||||||
|
f"({len(df):,} trades × {len(sl_mults)} SL × {len(rr_ratios)} RR)…")
|
||||||
|
|
||||||
|
for sl_mult in sl_mults:
|
||||||
|
for rr in rr_ratios:
|
||||||
|
r_multiples = []
|
||||||
|
outcomes = []
|
||||||
|
entry_times = []
|
||||||
|
pairs = []
|
||||||
|
tfs = []
|
||||||
|
for trade in df.itertuples(index=False):
|
||||||
|
# Recover ATR from original SL distance (engine used 1.5×ATR)
|
||||||
|
atr = abs(trade.entry - trade.sl) / 1.5
|
||||||
|
new_sl_dist = sl_mult * atr
|
||||||
|
if trade.direction == "BUY":
|
||||||
|
new_sl = trade.entry - new_sl_dist
|
||||||
|
new_tp = trade.entry + new_sl_dist * rr
|
||||||
|
else:
|
||||||
|
new_sl = trade.entry + new_sl_dist
|
||||||
|
new_tp = trade.entry - new_sl_dist * rr
|
||||||
|
|
||||||
|
hist_df = history.get((trade.symbol, trade.tf))
|
||||||
|
if hist_df is None:
|
||||||
|
continue
|
||||||
|
ts = pd.Timestamp(trade.entry_time)
|
||||||
|
# Forward bars (strictly after entry)
|
||||||
|
forward = hist_df[hist_df.index > ts].head(max_hold)
|
||||||
|
if forward.empty:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pip = pip_value_for(trade.symbol)
|
||||||
|
r, outcome = simulate_one(
|
||||||
|
trade.direction, trade.entry,
|
||||||
|
new_sl, new_tp, forward,
|
||||||
|
max_bars=max_hold, spread_pips=spread_pips,
|
||||||
|
pip_value=pip,
|
||||||
|
)
|
||||||
|
r_multiples.append(r)
|
||||||
|
outcomes.append(outcome)
|
||||||
|
entry_times.append(ts)
|
||||||
|
pairs.append(trade.pair)
|
||||||
|
tfs.append(trade.tf)
|
||||||
|
|
||||||
|
arr = np.array(r_multiples) if r_multiples else np.array([0.0])
|
||||||
|
wins = (arr > 0).sum()
|
||||||
|
losses = (arr <= 0).sum()
|
||||||
|
gross_win = arr[arr > 0].sum()
|
||||||
|
gross_loss = -arr[arr <= 0].sum()
|
||||||
|
pf = gross_win / gross_loss if gross_loss > 0 else float("inf")
|
||||||
|
cum = np.cumsum(arr)
|
||||||
|
running_peak = np.maximum.accumulate(cum)
|
||||||
|
max_dd = (cum - running_peak).min()
|
||||||
|
|
||||||
|
rows.append({
|
||||||
|
"sl_mult": sl_mult,
|
||||||
|
"rr": rr,
|
||||||
|
"n": len(arr),
|
||||||
|
"wins": int(wins),
|
||||||
|
"wr": wins / len(arr) * 100 if len(arr) else 0,
|
||||||
|
"avg_r": arr.mean() if len(arr) else 0,
|
||||||
|
"total_r": arr.sum(),
|
||||||
|
"pf": pf,
|
||||||
|
"max_dd": max_dd,
|
||||||
|
# Carry the per-trade results so we can split train/test downstream
|
||||||
|
"_arr": arr,
|
||||||
|
"_entry_times": entry_times,
|
||||||
|
"_pairs": pairs,
|
||||||
|
"_outcomes": outcomes,
|
||||||
|
})
|
||||||
|
return pd.DataFrame(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def split_summary(cell: pd.Series, train_frac: float = 0.7) -> dict:
|
||||||
|
"""Split a cell's per-trade results into train/test by entry_time."""
|
||||||
|
arr = cell["_arr"]
|
||||||
|
times = cell["_entry_times"]
|
||||||
|
if len(arr) == 0:
|
||||||
|
return {"train_total": 0, "train_wr": 0, "test_total": 0, "test_wr": 0}
|
||||||
|
order = np.argsort(times)
|
||||||
|
cutoff = int(len(arr) * train_frac)
|
||||||
|
train_idx = order[:cutoff]
|
||||||
|
test_idx = order[cutoff:]
|
||||||
|
train_arr = arr[train_idx]
|
||||||
|
test_arr = arr[test_idx]
|
||||||
|
return {
|
||||||
|
"train_n": len(train_arr),
|
||||||
|
"train_wr": (train_arr > 0).mean() * 100 if len(train_arr) else 0,
|
||||||
|
"train_avg": train_arr.mean() if len(train_arr) else 0,
|
||||||
|
"train_total": train_arr.sum(),
|
||||||
|
"test_n": len(test_arr),
|
||||||
|
"test_wr": (test_arr > 0).mean() * 100 if len(test_arr) else 0,
|
||||||
|
"test_avg": test_arr.mean() if len(test_arr) else 0,
|
||||||
|
"test_total": test_arr.sum(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--trades", default="backtest_results_all/trades.csv")
|
||||||
|
ap.add_argument("--out", default="backtest_results_all/geometry_grid.txt")
|
||||||
|
ap.add_argument("--max-hold", type=int, default=100)
|
||||||
|
ap.add_argument("--spread-pips", type=float, default=1.0)
|
||||||
|
ap.add_argument("--apply-phase1-filters", action="store_true",
|
||||||
|
help="Drop weekend trades, worst 4 hours, worst 3 pairs before grid")
|
||||||
|
ap.add_argument("--sl-mults", nargs="+", type=float,
|
||||||
|
default=[1.0, 1.5, 2.0, 2.5, 3.0])
|
||||||
|
ap.add_argument("--rr-ratios", nargs="+", type=float,
|
||||||
|
default=[1.0, 1.5, 2.0, 2.5])
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
p = Path(args.trades)
|
||||||
|
if not p.exists():
|
||||||
|
print(f"❌ {p} not found")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
df = pd.read_csv(p)
|
||||||
|
df["entry_time"] = pd.to_datetime(df["entry_time"])
|
||||||
|
df["hour"] = df["entry_time"].dt.hour
|
||||||
|
df["dow"] = df["entry_time"].dt.dayofweek
|
||||||
|
|
||||||
|
print(f"📊 Loaded {len(df):,} trades from {p}")
|
||||||
|
|
||||||
|
if args.apply_phase1_filters:
|
||||||
|
before = len(df)
|
||||||
|
df = df[~df["hour"].isin(PHASE1_BAD_HOURS)]
|
||||||
|
df = df[~df["pair"].isin(PHASE1_BAD_PAIRS)]
|
||||||
|
df = df[~df["dow"].isin(PHASE1_BAD_DOWS)]
|
||||||
|
df = df.reset_index(drop=True)
|
||||||
|
print(f" Phase 1 filters applied: {before:,} → {len(df):,} trades "
|
||||||
|
f"({len(df)/before*100:.0f}% retained)")
|
||||||
|
|
||||||
|
# Load history for forward simulation
|
||||||
|
print(f"📁 Loading history…")
|
||||||
|
pairs = sorted(df["symbol"].unique().tolist())
|
||||||
|
tfs = sorted({_TFSTR_TO_ENUM[t] for t in df["tf"].unique() if t in _TFSTR_TO_ENUM},
|
||||||
|
key=lambda x: x.value)
|
||||||
|
history_raw = load_history(pairs, tfs)
|
||||||
|
# Rekey by (symbol, tf_string) to match trades.csv
|
||||||
|
history = {}
|
||||||
|
for (sym, tf_enum), hdf in history_raw.items():
|
||||||
|
history[(sym, tf_enum.value)] = hdf
|
||||||
|
|
||||||
|
print(f"\n🧮 Grid search:")
|
||||||
|
print(f" SL multipliers (× ATR): {args.sl_mults}")
|
||||||
|
print(f" RR ratios: {args.rr_ratios}\n")
|
||||||
|
|
||||||
|
grid = grid_run(
|
||||||
|
df, history, args.sl_mults, args.rr_ratios,
|
||||||
|
max_hold=args.max_hold, spread_pips=args.spread_pips,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Heatmap (full-data total R) ─────────────────────────────────
|
||||||
|
pivot_total = grid.pivot(index="sl_mult", columns="rr", values="total_r").round(1)
|
||||||
|
pivot_wr = grid.pivot(index="sl_mult", columns="rr", values="wr").round(1)
|
||||||
|
pivot_avg = grid.pivot(index="sl_mult", columns="rr", values="avg_r").round(3)
|
||||||
|
pivot_pf = grid.pivot(index="sl_mult", columns="rr", values="pf").round(2)
|
||||||
|
|
||||||
|
out_lines = []
|
||||||
|
def emit(line: str = ""):
|
||||||
|
print(line)
|
||||||
|
out_lines.append(line)
|
||||||
|
|
||||||
|
emit("=" * 80)
|
||||||
|
emit(f"GEOMETRY GRID — {len(df):,} trades")
|
||||||
|
emit(f" filters applied: {args.apply_phase1_filters}")
|
||||||
|
emit(f" spread cost: {args.spread_pips} pip")
|
||||||
|
emit(f" max hold: {args.max_hold} bars")
|
||||||
|
emit("=" * 80)
|
||||||
|
emit()
|
||||||
|
emit("─── Total R (rows = SL multiplier × ATR; cols = RR ratio) ───")
|
||||||
|
emit(pivot_total.to_string())
|
||||||
|
emit()
|
||||||
|
emit("─── Win rate % ───")
|
||||||
|
emit(pivot_wr.to_string())
|
||||||
|
emit()
|
||||||
|
emit("─── Avg R per trade ───")
|
||||||
|
emit(pivot_avg.to_string())
|
||||||
|
emit()
|
||||||
|
emit("─── Profit factor ───")
|
||||||
|
emit(pivot_pf.to_string())
|
||||||
|
emit()
|
||||||
|
|
||||||
|
# ── Train/Test discipline on top cells ──────────────────────────
|
||||||
|
grid_sorted = grid.sort_values("total_r", ascending=False)
|
||||||
|
emit("=" * 80)
|
||||||
|
emit("TOP 5 CELLS (by full-period total R) — with TRAIN / TEST split discipline")
|
||||||
|
emit("=" * 80)
|
||||||
|
for _, cell in grid_sorted.head(5).iterrows():
|
||||||
|
s = split_summary(cell)
|
||||||
|
emit(
|
||||||
|
f" SL={cell['sl_mult']:.1f}×ATR RR=1:{cell['rr']:.1f} "
|
||||||
|
f"| full: {cell['n']} trades WR {cell['wr']:.1f}% "
|
||||||
|
f"avgR {cell['avg_r']:+.3f} totalR {cell['total_r']:+.1f} "
|
||||||
|
f"PF {cell['pf']:.2f}"
|
||||||
|
)
|
||||||
|
emit(
|
||||||
|
f" train ({s['train_n']}): "
|
||||||
|
f"WR {s['train_wr']:.1f}% avgR {s['train_avg']:+.3f} totalR {s['train_total']:+.1f}"
|
||||||
|
)
|
||||||
|
emit(
|
||||||
|
f" test ({s['test_n']}): "
|
||||||
|
f"WR {s['test_wr']:.1f}% avgR {s['test_avg']:+.3f} totalR {s['test_total']:+.1f}"
|
||||||
|
)
|
||||||
|
emit()
|
||||||
|
|
||||||
|
# ── Bottom 3 for contrast ───────────────────────────────────────
|
||||||
|
emit("─── BOTTOM 3 cells (worst total R) ───")
|
||||||
|
for _, cell in grid_sorted.tail(3).iterrows():
|
||||||
|
emit(
|
||||||
|
f" SL={cell['sl_mult']:.1f}×ATR RR=1:{cell['rr']:.1f} "
|
||||||
|
f"totalR {cell['total_r']:+.1f} WR {cell['wr']:.1f}%"
|
||||||
|
)
|
||||||
|
emit()
|
||||||
|
|
||||||
|
# ── Verdict ─────────────────────────────────────────────────────
|
||||||
|
emit("=" * 80)
|
||||||
|
emit("VERDICT")
|
||||||
|
emit("=" * 80)
|
||||||
|
best = grid_sorted.iloc[0]
|
||||||
|
s = split_summary(best)
|
||||||
|
emit(
|
||||||
|
f" Best cell: SL={best['sl_mult']:.1f}×ATR, RR=1:{best['rr']:.1f} "
|
||||||
|
f"(full totalR {best['total_r']:+.1f})"
|
||||||
|
)
|
||||||
|
emit(
|
||||||
|
f" Test slice: {s['test_n']} trades, WR {s['test_wr']:.1f}%, "
|
||||||
|
f"totalR {s['test_total']:+.1f}, avgR {s['test_avg']:+.3f}"
|
||||||
|
)
|
||||||
|
if s["test_total"] > 0:
|
||||||
|
emit(" ✅ Best geometry is profitable on held-out test set.")
|
||||||
|
emit(" → Phase 3A: hardcode in _generate_trade_setup, re-run end-to-end.")
|
||||||
|
elif s["test_avg"] > -0.05:
|
||||||
|
emit(" ⚠️ Best geometry is near-break-even on test set.")
|
||||||
|
emit(" → Phase 3A worth attempting; combine with Phase 1 filters for margin.")
|
||||||
|
else:
|
||||||
|
emit(" ❌ No geometry is profitable on held-out test set.")
|
||||||
|
emit(" → Phase 3B: M15 confluence is structurally not edge-bearing.")
|
||||||
|
emit(" Consider H4 primary, mean-reversion-only, or indicator-level meta-model.")
|
||||||
|
|
||||||
|
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
Path(args.out).write_text("\n".join(out_lines))
|
||||||
|
emit()
|
||||||
|
emit(f"📁 Saved: {args.out}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+368
@@ -0,0 +1,368 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Meta-model: learn an empirical "will this trade win?" probability from
|
||||||
|
backtest output, replacing the engine's heuristic A+/A/B grades with a
|
||||||
|
calibrated win-probability score.
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
backtest_results/trades.csv (or --trades <path>)
|
||||||
|
|
||||||
|
Outputs (under backtest_results/meta/):
|
||||||
|
feature_importances.txt
|
||||||
|
classification_report.txt
|
||||||
|
calibration.png — predicted prob vs actual win rate
|
||||||
|
decile_returns.png — avg R per predicted-prob decile
|
||||||
|
equity_compare.png — engine grades vs meta-top-N
|
||||||
|
summary.txt — headline numbers + recommended threshold
|
||||||
|
|
||||||
|
Train/test split is **time-based** (first 70% train, last 30% test) — random
|
||||||
|
splits leak future regime information into training and overstate test
|
||||||
|
performance on financial time series.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python meta_model.py
|
||||||
|
python meta_model.py --trades backtest_results_h1/trades.csv --out meta_h1
|
||||||
|
python meta_model.py --top-decile 0.2 # take top 20% predictions
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def engineer_features(df: pd.DataFrame) -> Tuple[pd.DataFrame, pd.Series]:
|
||||||
|
"""
|
||||||
|
Build features available at trade entry time. Strict no-leakage:
|
||||||
|
nothing from exit_time, exit_price, outcome, bars_held, r_multiple.
|
||||||
|
"""
|
||||||
|
df = df.copy()
|
||||||
|
df["entry_time"] = pd.to_datetime(df["entry_time"])
|
||||||
|
|
||||||
|
# Time-of-day / day-of-week
|
||||||
|
df["hour"] = df["entry_time"].dt.hour
|
||||||
|
df["dow"] = df["entry_time"].dt.dayofweek
|
||||||
|
df["is_buy"] = (df["direction"] == "BUY").astype(int)
|
||||||
|
|
||||||
|
# MTF flag
|
||||||
|
df["mtf"] = df["mtf_aligned"].astype(int)
|
||||||
|
|
||||||
|
# Score / confidence — keep raw and signed/abs versions; model picks
|
||||||
|
df["abs_score"] = df["score"].abs()
|
||||||
|
df["score_sign"] = np.sign(df["score"])
|
||||||
|
|
||||||
|
# Risk geometry
|
||||||
|
df["rr"] = df["reward_pips"] / df["risk_pips"].replace(0, np.nan)
|
||||||
|
df["rr"] = df["rr"].fillna(2.0)
|
||||||
|
|
||||||
|
# Regime one-hot
|
||||||
|
regime_dummies = pd.get_dummies(df["regime"], prefix="regime").astype(int)
|
||||||
|
df = pd.concat([df, regime_dummies], axis=1)
|
||||||
|
|
||||||
|
# Pair one-hot (model can learn pair-specific behavior)
|
||||||
|
pair_dummies = pd.get_dummies(df["pair"], prefix="pair").astype(int)
|
||||||
|
df = pd.concat([df, pair_dummies], axis=1)
|
||||||
|
|
||||||
|
# Grade one-hot — included so the meta-model can re-weight rather
|
||||||
|
# than blindly disregard the engine's view
|
||||||
|
grade_dummies = pd.get_dummies(df["grade"], prefix="grade").astype(int)
|
||||||
|
df = pd.concat([df, grade_dummies], axis=1)
|
||||||
|
|
||||||
|
feature_cols = (
|
||||||
|
["score", "abs_score", "score_sign", "confidence", "rr",
|
||||||
|
"is_buy", "mtf", "hour", "dow", "risk_pips", "reward_pips"]
|
||||||
|
+ list(regime_dummies.columns)
|
||||||
|
+ list(pair_dummies.columns)
|
||||||
|
+ list(grade_dummies.columns)
|
||||||
|
)
|
||||||
|
|
||||||
|
X = df[feature_cols].astype(float)
|
||||||
|
y = (df["r_multiple"] > 0).astype(int)
|
||||||
|
return X, y
|
||||||
|
|
||||||
|
|
||||||
|
def time_based_split(
|
||||||
|
X: pd.DataFrame, y: pd.Series, df: pd.DataFrame, train_frac: float = 0.7,
|
||||||
|
):
|
||||||
|
"""First 70% by entry_time = train, last 30% = test. Standard for time series."""
|
||||||
|
order = df["entry_time"].argsort().values
|
||||||
|
cutoff = int(len(order) * train_frac)
|
||||||
|
train_idx = order[:cutoff]
|
||||||
|
test_idx = order[cutoff:]
|
||||||
|
return (
|
||||||
|
X.iloc[train_idx], X.iloc[test_idx],
|
||||||
|
y.iloc[train_idx], y.iloc[test_idx],
|
||||||
|
df.iloc[train_idx], df.iloc[test_idx],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_decile_strategy(
|
||||||
|
df_test: pd.DataFrame, p_win: np.ndarray, top_frac: float = 0.2,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Take only the top X% by predicted win probability. Compare to taking
|
||||||
|
every trade in the test set (the 'engine baseline').
|
||||||
|
"""
|
||||||
|
n = len(df_test)
|
||||||
|
cutoff_n = max(1, int(n * top_frac))
|
||||||
|
|
||||||
|
df_eval = df_test.copy()
|
||||||
|
df_eval["p_win"] = p_win
|
||||||
|
df_eval = df_eval.sort_values("p_win", ascending=False)
|
||||||
|
|
||||||
|
top = df_eval.head(cutoff_n)
|
||||||
|
base = df_eval
|
||||||
|
|
||||||
|
return {
|
||||||
|
"top_frac": top_frac,
|
||||||
|
"top_n": len(top),
|
||||||
|
"top_wr": (top["r_multiple"] > 0).mean() * 100,
|
||||||
|
"top_avg_r": top["r_multiple"].mean(),
|
||||||
|
"top_total_r": top["r_multiple"].sum(),
|
||||||
|
"base_n": len(base),
|
||||||
|
"base_wr": (base["r_multiple"] > 0).mean() * 100,
|
||||||
|
"base_avg_r": base["r_multiple"].mean(),
|
||||||
|
"base_total_r": base["r_multiple"].sum(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Meta-model on backtest trades.")
|
||||||
|
parser.add_argument("--trades", default="backtest_results/trades.csv",
|
||||||
|
help="Path to trades.csv (default backtest_results/trades.csv)")
|
||||||
|
parser.add_argument("--out", default="backtest_results/meta",
|
||||||
|
help="Output directory")
|
||||||
|
parser.add_argument("--train-frac", type=float, default=0.7)
|
||||||
|
parser.add_argument("--top-decile", type=float, default=0.2,
|
||||||
|
help="Top fraction of predictions to 'trade' (default 0.2)")
|
||||||
|
parser.add_argument("--min-trades", type=int, default=300,
|
||||||
|
help="Refuse to train below this many trades")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from sklearn.linear_model import LogisticRegression
|
||||||
|
from sklearn.ensemble import GradientBoostingClassifier
|
||||||
|
from sklearn.preprocessing import StandardScaler
|
||||||
|
from sklearn.pipeline import Pipeline
|
||||||
|
from sklearn.metrics import (
|
||||||
|
classification_report, roc_auc_score, brier_score_loss,
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
print("❌ scikit-learn not installed. pip install scikit-learn")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
trades_path = Path(args.trades)
|
||||||
|
if not trades_path.exists():
|
||||||
|
print(f"❌ {trades_path} not found. Run backtest.py first.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
out_dir = Path(args.out)
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
df = pd.read_csv(trades_path)
|
||||||
|
df["entry_time"] = pd.to_datetime(df["entry_time"])
|
||||||
|
if len(df) < args.min_trades:
|
||||||
|
print(f"❌ Only {len(df)} trades — need ≥{args.min_trades} for training.")
|
||||||
|
print(" Seed more pairs / longer history, or lower --min-trades.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"📊 Loaded {len(df):,} trades from {trades_path}")
|
||||||
|
print(f" Pairs: {sorted(df['pair'].unique())}")
|
||||||
|
print(f" Date range: {df['entry_time'].min()} → {df['entry_time'].max()}")
|
||||||
|
print(f" Baseline win rate: {(df['r_multiple'] > 0).mean()*100:.1f}%")
|
||||||
|
print(f" Baseline avg R: {df['r_multiple'].mean():+.3f}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
X, y = engineer_features(df)
|
||||||
|
print(f"🔧 Features: {list(X.columns)}")
|
||||||
|
print(f" Shape: {X.shape}, target balance: {y.mean()*100:.1f}% wins\n")
|
||||||
|
|
||||||
|
X_tr, X_te, y_tr, y_te, df_tr, df_te = time_based_split(X, y, df, args.train_frac)
|
||||||
|
print(f"📚 Train: {len(X_tr):,} trades ({df_tr['entry_time'].min().date()} → {df_tr['entry_time'].max().date()})")
|
||||||
|
print(f" Test: {len(X_te):,} trades ({df_te['entry_time'].min().date()} → {df_te['entry_time'].max().date()})\n")
|
||||||
|
|
||||||
|
# ── Model 1: Logistic regression (interpretable baseline) ─────────
|
||||||
|
lr = Pipeline([
|
||||||
|
("scale", StandardScaler()),
|
||||||
|
("lr", LogisticRegression(max_iter=2000, C=1.0)),
|
||||||
|
])
|
||||||
|
lr.fit(X_tr, y_tr)
|
||||||
|
p_lr = lr.predict_proba(X_te)[:, 1]
|
||||||
|
|
||||||
|
# ── Model 2: Gradient boosting (non-linear, captures interactions) ─
|
||||||
|
gb = GradientBoostingClassifier(
|
||||||
|
n_estimators=200, max_depth=3, learning_rate=0.05,
|
||||||
|
subsample=0.8, random_state=42,
|
||||||
|
)
|
||||||
|
gb.fit(X_tr, y_tr)
|
||||||
|
p_gb = gb.predict_proba(X_te)[:, 1]
|
||||||
|
|
||||||
|
# ── Metrics ───────────────────────────────────────────────────────
|
||||||
|
print("══ Logistic Regression ══")
|
||||||
|
print(f" AUC: {roc_auc_score(y_te, p_lr):.3f}")
|
||||||
|
print(f" Brier: {brier_score_loss(y_te, p_lr):.3f} (lower=better, 0.25=random for 50% prior)")
|
||||||
|
print()
|
||||||
|
print("══ Gradient Boosting ══")
|
||||||
|
print(f" AUC: {roc_auc_score(y_te, p_gb):.3f}")
|
||||||
|
print(f" Brier: {brier_score_loss(y_te, p_gb):.3f}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Pick the better-AUC model for downstream evaluation
|
||||||
|
p_best = p_gb if roc_auc_score(y_te, p_gb) >= roc_auc_score(y_te, p_lr) else p_lr
|
||||||
|
best_name = "GradientBoosting" if p_best is p_gb else "LogisticRegression"
|
||||||
|
print(f"➡️ Selecting {best_name} for top-decile evaluation\n")
|
||||||
|
|
||||||
|
# ── Decile-strategy comparison ─────────────────────────────────────
|
||||||
|
res = evaluate_decile_strategy(df_te, p_best, args.top_decile)
|
||||||
|
print(f"══ {int(args.top_decile*100)}% top-pick strategy vs trade-everything baseline (test set) ══")
|
||||||
|
print(f" Trade everything: {res['base_n']:>5} trades WR {res['base_wr']:5.1f}% avgR {res['base_avg_r']:+.3f} totalR {res['base_total_r']:+8.2f}")
|
||||||
|
print(f" Top {int(args.top_decile*100)}% by p_win: {res['top_n']:>5} trades WR {res['top_wr']:5.1f}% avgR {res['top_avg_r']:+.3f} totalR {res['top_total_r']:+8.2f}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ── Per-decile breakdown ──────────────────────────────────────────
|
||||||
|
df_eval = df_te.copy()
|
||||||
|
df_eval["p_win"] = p_best
|
||||||
|
df_eval["decile"] = pd.qcut(df_eval["p_win"], 10, labels=False, duplicates="drop")
|
||||||
|
decile_summary = df_eval.groupby("decile").agg(
|
||||||
|
n=("r_multiple", "count"),
|
||||||
|
avg_p_win=("p_win", "mean"),
|
||||||
|
actual_wr=("r_multiple", lambda s: (s > 0).mean()),
|
||||||
|
avg_r=("r_multiple", "mean"),
|
||||||
|
total_r=("r_multiple", "sum"),
|
||||||
|
).round(3)
|
||||||
|
decile_summary.index = decile_summary.index.astype(int) + 1 # 1..10
|
||||||
|
print("══ Per-decile breakdown (1=lowest p_win, 10=highest) ══")
|
||||||
|
print(decile_summary.to_string())
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ── Feature importances ────────────────────────────────────────────
|
||||||
|
print("══ Top features by importance (from gradient boosting) ══")
|
||||||
|
importances = pd.Series(gb.feature_importances_, index=X.columns)
|
||||||
|
top_features = importances.sort_values(ascending=False).head(15)
|
||||||
|
for name, imp in top_features.items():
|
||||||
|
print(f" {name:30s} {imp:.4f}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ── Save full reports ──────────────────────────────────────────────
|
||||||
|
(out_dir / "classification_report.txt").write_text(
|
||||||
|
f"Logistic Regression\n{'-'*40}\n"
|
||||||
|
f"AUC: {roc_auc_score(y_te, p_lr):.3f}\n"
|
||||||
|
f"Brier: {brier_score_loss(y_te, p_lr):.3f}\n\n"
|
||||||
|
f"{classification_report(y_te, (p_lr > 0.5).astype(int))}\n"
|
||||||
|
f"\nGradient Boosting\n{'-'*40}\n"
|
||||||
|
f"AUC: {roc_auc_score(y_te, p_gb):.3f}\n"
|
||||||
|
f"Brier: {brier_score_loss(y_te, p_gb):.3f}\n\n"
|
||||||
|
f"{classification_report(y_te, (p_gb > 0.5).astype(int))}\n"
|
||||||
|
)
|
||||||
|
(out_dir / "feature_importances.txt").write_text(
|
||||||
|
importances.sort_values(ascending=False).to_string()
|
||||||
|
)
|
||||||
|
(out_dir / "decile_breakdown.csv").write_text(decile_summary.to_csv())
|
||||||
|
(out_dir / "summary.txt").write_text(
|
||||||
|
f"Meta-model on {len(df):,} trades "
|
||||||
|
f"({df['entry_time'].min()} → {df['entry_time'].max()})\n\n"
|
||||||
|
f"Baseline (trade everything):\n"
|
||||||
|
f" win rate: {(df['r_multiple'] > 0).mean()*100:.1f}%\n"
|
||||||
|
f" avg R: {df['r_multiple'].mean():+.3f}\n"
|
||||||
|
f" total R: {df['r_multiple'].sum():+.2f}\n\n"
|
||||||
|
f"Test set ({len(X_te):,} trades, last 30% by date):\n"
|
||||||
|
f" Trade everything: WR {res['base_wr']:.1f}% avgR {res['base_avg_r']:+.3f} totalR {res['base_total_r']:+.2f}\n"
|
||||||
|
f" Top {int(args.top_decile*100)}% by p_win: WR {res['top_wr']:.1f}% avgR {res['top_avg_r']:+.3f} totalR {res['top_total_r']:+.2f}\n\n"
|
||||||
|
f"Best model: {best_name}\n"
|
||||||
|
f" AUC: {roc_auc_score(y_te, p_best):.3f} (0.5 = random, >0.55 = real signal)\n"
|
||||||
|
f" Brier: {brier_score_loss(y_te, p_best):.3f}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Plots ─────────────────────────────────────────────────────────
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
|
||||||
|
# Calibration curve
|
||||||
|
bins = pd.cut(p_best, bins=np.linspace(0, 1, 11))
|
||||||
|
cal = pd.DataFrame({"p_win": p_best, "y": y_te.values, "bin": bins})
|
||||||
|
grouped = cal.groupby("bin", observed=True).agg(
|
||||||
|
mean_pred=("p_win", "mean"),
|
||||||
|
actual=("y", "mean"),
|
||||||
|
n=("y", "count"),
|
||||||
|
)
|
||||||
|
fig, ax = plt.subplots(figsize=(6, 5))
|
||||||
|
ax.plot([0, 1], [0, 1], "--", color="gray", label="Perfect calibration")
|
||||||
|
ax.plot(grouped["mean_pred"], grouped["actual"], "o-", linewidth=2, markersize=8)
|
||||||
|
for _, row in grouped.iterrows():
|
||||||
|
ax.annotate(f"n={int(row['n'])}",
|
||||||
|
(row["mean_pred"], row["actual"]),
|
||||||
|
textcoords="offset points", xytext=(8, -3), fontsize=8)
|
||||||
|
ax.set_xlabel("Mean predicted win probability")
|
||||||
|
ax.set_ylabel("Actual win rate")
|
||||||
|
ax.set_title(f"Calibration ({best_name}, test set)")
|
||||||
|
ax.set_xlim(0, 1); ax.set_ylim(0, 1)
|
||||||
|
ax.legend(); ax.grid(alpha=0.3)
|
||||||
|
fig.tight_layout()
|
||||||
|
fig.savefig(out_dir / "calibration.png", dpi=120)
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
# Decile returns
|
||||||
|
fig, ax = plt.subplots(figsize=(8, 5))
|
||||||
|
colors = ["#dc2626" if r < 0 else "#16a34a" for r in decile_summary["avg_r"]]
|
||||||
|
ax.bar(decile_summary.index.astype(str), decile_summary["avg_r"], color=colors)
|
||||||
|
ax.axhline(0, color="black", linewidth=0.5)
|
||||||
|
ax.set_xlabel("Predicted-probability decile (1=lowest, 10=highest)")
|
||||||
|
ax.set_ylabel("Average R per trade")
|
||||||
|
ax.set_title(f"Trade outcome by predicted-probability decile ({best_name})")
|
||||||
|
for i, (idx, row) in enumerate(decile_summary.iterrows()):
|
||||||
|
ax.text(i, row["avg_r"] + (0.02 if row["avg_r"] >= 0 else -0.05),
|
||||||
|
f"n={int(row['n'])}\nWR={row['actual_wr']*100:.0f}%",
|
||||||
|
ha="center", fontsize=8)
|
||||||
|
ax.grid(alpha=0.3, axis="y")
|
||||||
|
fig.tight_layout()
|
||||||
|
fig.savefig(out_dir / "decile_returns.png", dpi=120)
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
# Equity comparison: trade-everything vs top-decile (test set, time-ordered)
|
||||||
|
df_compare = df_te.copy().sort_values("entry_time").reset_index(drop=True)
|
||||||
|
df_compare["p_win"] = p_best
|
||||||
|
threshold = np.quantile(p_best, 1 - args.top_decile)
|
||||||
|
df_compare["take_top"] = (df_compare["p_win"] >= threshold).astype(int)
|
||||||
|
df_compare["base_cum"] = df_compare["r_multiple"].cumsum()
|
||||||
|
df_compare["top_cum"] = (df_compare["r_multiple"] * df_compare["take_top"]).cumsum()
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(11, 5))
|
||||||
|
ax.plot(df_compare["entry_time"], df_compare["base_cum"],
|
||||||
|
label="Trade everything (engine baseline)", linewidth=1.5)
|
||||||
|
ax.plot(df_compare["entry_time"], df_compare["top_cum"],
|
||||||
|
label=f"Top {int(args.top_decile*100)}% by p_win (meta-model)",
|
||||||
|
linewidth=1.8)
|
||||||
|
ax.axhline(0, color="gray", linewidth=0.5)
|
||||||
|
ax.set_xlabel("Trade entry time")
|
||||||
|
ax.set_ylabel("Cumulative R")
|
||||||
|
ax.set_title(f"Cumulative R: engine baseline vs meta-model top-{int(args.top_decile*100)}% (test set)")
|
||||||
|
ax.legend(); ax.grid(alpha=0.3)
|
||||||
|
fig.tight_layout()
|
||||||
|
fig.savefig(out_dir / "equity_compare.png", dpi=120)
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
print(f"📁 Reports → {out_dir}/")
|
||||||
|
print(f" • classification_report.txt")
|
||||||
|
print(f" • feature_importances.txt")
|
||||||
|
print(f" • decile_breakdown.csv")
|
||||||
|
print(f" • summary.txt")
|
||||||
|
print(f"📈 Plots:")
|
||||||
|
print(f" • calibration.png")
|
||||||
|
print(f" • decile_returns.png")
|
||||||
|
print(f" • equity_compare.png")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -11,3 +11,6 @@ python-dotenv>=1.0 # loads GEMINI_API_KEY and TELEGRAM_BOT_TOKEN from .env
|
|||||||
|
|
||||||
# Telegram bot
|
# Telegram bot
|
||||||
python-telegram-bot[job-queue]>=20.0
|
python-telegram-bot[job-queue]>=20.0
|
||||||
|
|
||||||
|
# Backtest analysis (only needed for `python backtest.py --plot`)
|
||||||
|
matplotlib>=3.7
|
||||||
|
|||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
One-shot historical OHLCV seeder for backtesting.
|
||||||
|
|
||||||
|
Downloads bars from Twelve Data for the configured pairs/timeframes,
|
||||||
|
paginated to stay within the 5000-bar per-call limit, and writes each
|
||||||
|
(symbol, timeframe) to history/<PAIR>_<TF>.csv.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python seed_history.py --years 1
|
||||||
|
python seed_history.py --years 2 --pairs EURUSD GBPUSD USDJPY
|
||||||
|
python seed_history.py --tfs M15 H1 D1 --years 1 --force
|
||||||
|
|
||||||
|
Cost (free tier, 800 credits/day):
|
||||||
|
1 year M15 + H1 + D1 × 7 majors ≈ 56 credits
|
||||||
|
5 years same ≈ 231 credits
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
from config import MAJOR_PAIRS, PlatformConfig, Timeframe
|
||||||
|
from data_provider import DataUnavailableError, ForexDataProvider
|
||||||
|
|
||||||
|
DEFAULT_TFS = ["M15", "H1", "D1"]
|
||||||
|
HISTORY_DIR = Path("history")
|
||||||
|
|
||||||
|
_TF_MAP = {
|
||||||
|
"M1": Timeframe.M1, "M5": Timeframe.M5,
|
||||||
|
"M15": Timeframe.M15, "M30": Timeframe.M30,
|
||||||
|
"H1": Timeframe.H1, "H4": Timeframe.H4,
|
||||||
|
"D1": Timeframe.D1, "W1": Timeframe.W1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def csv_path(out_dir: Path, symbol: str, tf: Timeframe) -> Path:
|
||||||
|
return out_dir / f"{symbol.replace('=X', '')}_{tf.value}.csv"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Seed historical OHLCV for backtesting (Twelve Data, free tier).",
|
||||||
|
)
|
||||||
|
parser.add_argument("--years", type=float, default=1.0,
|
||||||
|
help="Years of history to download (default 1.0)")
|
||||||
|
parser.add_argument("--pairs", nargs="+", default=None,
|
||||||
|
help="Pair symbols, e.g. EURUSD GBPUSD (default all 7 majors)")
|
||||||
|
parser.add_argument("--tfs", nargs="+", default=DEFAULT_TFS,
|
||||||
|
help=f"Timeframes (default {' '.join(DEFAULT_TFS)})")
|
||||||
|
parser.add_argument("--out", default=str(HISTORY_DIR),
|
||||||
|
help="Output directory (default history/)")
|
||||||
|
parser.add_argument("--force", action="store_true",
|
||||||
|
help="Re-download even if a CSV already exists")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
out_dir = Path(args.out)
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
pairs = args.pairs if args.pairs else MAJOR_PAIRS
|
||||||
|
pairs = [p if p.endswith("=X") else f"{p}=X" for p in pairs]
|
||||||
|
|
||||||
|
try:
|
||||||
|
tfs = [_TF_MAP[t.upper()] for t in args.tfs]
|
||||||
|
except KeyError as e:
|
||||||
|
print(f"❌ Unknown timeframe: {e}. Valid: {list(_TF_MAP.keys())}")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
end = datetime.now(timezone.utc)
|
||||||
|
start = end - timedelta(days=int(args.years * 365))
|
||||||
|
|
||||||
|
provider = ForexDataProvider(PlatformConfig())
|
||||||
|
|
||||||
|
total = len(pairs) * len(tfs)
|
||||||
|
print(f"📥 Seeding {total} pair × tf combinations from "
|
||||||
|
f"{start.date()} to {end.date()} ({args.years} year(s))")
|
||||||
|
print(f" Pairs: {[p.replace('=X', '') for p in pairs]}")
|
||||||
|
print(f" TFs: {[tf.value for tf in tfs]}")
|
||||||
|
print(f" Output: {out_dir}/\n")
|
||||||
|
|
||||||
|
done = 0
|
||||||
|
failed: list = []
|
||||||
|
for symbol in pairs:
|
||||||
|
for tf in tfs:
|
||||||
|
done += 1
|
||||||
|
out_path = csv_path(out_dir, symbol, tf)
|
||||||
|
|
||||||
|
if out_path.exists() and not args.force:
|
||||||
|
print(f"[{done}/{total}] ⏭ {symbol} {tf.value} — exists, "
|
||||||
|
f"skipping (--force to overwrite)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"[{done}/{total}] 📥 {symbol} {tf.value}…", flush=True)
|
||||||
|
try:
|
||||||
|
df = provider.fetch_historical(symbol, tf, start, end)
|
||||||
|
df.to_csv(out_path)
|
||||||
|
print(f" ✅ {len(df):,} bars "
|
||||||
|
f"({df.index.min().date()} → {df.index.max().date()}) "
|
||||||
|
f"→ {out_path}")
|
||||||
|
except DataUnavailableError as e:
|
||||||
|
print(f" ❌ {e}")
|
||||||
|
failed.append((symbol, tf.value, str(e)))
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ unexpected: {e}")
|
||||||
|
failed.append((symbol, tf.value, str(e)))
|
||||||
|
|
||||||
|
print()
|
||||||
|
if failed:
|
||||||
|
print(f"⚠️ {len(failed)} fetch(es) failed:")
|
||||||
|
for sym, tfn, msg in failed:
|
||||||
|
print(f" {sym} {tfn}: {msg}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("✅ Seeding complete. Run: python backtest.py --primary M15")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+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