""" backtest_current.py — 对已训练的 forex 组因子做完整回测分析 用法: python backtest_current.py --offline 加载 strategies/best_forex*.json 中的因子,在 EURUSD + USDJPY 全量历史数据上回测, 输出:对比表、品种级详情、过拟合诊断、资金曲线图。 """ import sys, json, math from pathlib import Path import numpy as np import torch import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import matplotlib.gridspec as gridspec sys.path.insert(0, str(Path(__file__).parent)) from config import Config from data_pipeline.data_manager import MT5DataManager from data_pipeline.fetcher import MT5DataFetcher from model_core.vocab import FORMULA_VOCAB, VOCAB_VERSION from model_core.features import MT5FeatureEngineer from backtest_elite import backtest_one, decode, calc_sharpe, calc_sortino, calc_mdd, calc_ic _H1_PER_YEAR = 6240 OUTPUT_DIR = "backtest_output" FOREX_SYMS = ["EURUSD", "USDJPY"] def load_formula(path: Path) -> tuple[list[int], str, float] | None: """从 JSON 加载因子,返回 (formula, readable, score)。""" if not path.exists(): return None data = json.load(open(path)) if data.get("vocab_version", "unknown") != VOCAB_VERSION: print(f" [跳过] {path.name}: vocab 版本不符") return None return data["formula"], decode(data["formula"]), data.get("best_score", 0.0) def plot_equity_curves(results: list[dict], times_arr: np.ndarray, output_dir: str): """绘制各因子的组合资金曲线对比 + 回撤。""" Path(output_dir).mkdir(parents=True, exist_ok=True) n = len(results) fig = plt.figure(figsize=(16, 9), dpi=110) gs = gridspec.GridSpec(2, 1, height_ratios=[3, 1], hspace=0.12) ax_eq = fig.add_subplot(gs[0]) ax_dd = fig.add_subplot(gs[1], sharex=ax_eq) colors = ["#1565c0", "#e65100", "#00897b", "#6a1b9a", "#b71c1c"] T = len(results[0]["port_pnl"]) x = np.arange(T) for i, r in enumerate(results): cum = r["port_cum"] label = f"{r['label']} (Sortino={r['port_sortino']:+.2f}, Ret={cum[-1]:+.3f})" ax_eq.plot(x, cum, linewidth=1.8, color=colors[i % len(colors)], label=label) # 回撤 peak = np.maximum.accumulate(cum) dd = cum - peak ax_dd.fill_between(x, dd, 0, alpha=0.35, color=colors[i % len(colors)], label=r["label"]) ax_eq.axhline(0, color="gray", linewidth=0.5, linestyle="--") ax_eq.set_ylabel("Cumulative Log Return", fontsize=10) ax_eq.legend(loc="upper left", fontsize=9, framealpha=0.85) ax_eq.grid(alpha=0.25) ax_eq.set_title("Forex 组因子回测对比 | EURUSD + USDJPY 等权组合", fontsize=12, pad=8) ax_dd.axhline(0, color="gray", linewidth=0.5) ax_dd.set_ylabel("Drawdown", fontsize=9) ax_dd.grid(alpha=0.2) # X 轴时间刻度 if times_arr is not None and len(times_arr) == T: from datetime import datetime, timezone step = max(1, T // 10) ticks = x[::step] labels = [datetime.fromtimestamp(int(times_arr[i]), tz=timezone.utc).strftime("%y-%m-%d") for i in range(0, T, step)] ax_dd.set_xticks(ticks) ax_dd.set_xticklabels(labels[:len(ticks)], fontsize=8, rotation=20) plt.setp(ax_eq.get_xticklabels(), visible=False) path = str(Path(output_dir) / "forex_factors_equity.png") fig.savefig(path, bbox_inches="tight") plt.close(fig) print(f" 资金曲线图已保存 → {path}") return path def plot_per_symbol(results: list[dict], output_dir: str): """各因子分品种的资金曲线(子图)。""" n = len(results) fig, axes = plt.subplots(n, 1, figsize=(14, 3.2 * n), dpi=110, sharex=True) if n == 1: axes = [axes] colors = {"EURUSD": "#1565c0", "USDJPY": "#e65100"} for i, r in enumerate(results): ax = axes[i] for sym, d in r["per_sym"].items(): ax.plot(d["cum"], linewidth=1.4, color=colors.get(sym, "gray"), label=f"{sym} (Sortino={d['sortino']:+.2f})") ax.axhline(0, color="gray", linewidth=0.5, linestyle="--") ax.set_ylabel("Cum PnL", fontsize=9) ax.legend(loc="upper left", fontsize=8, framealpha=0.8) ax.grid(alpha=0.25) ax.set_title(f"{r['label']} score={r['score']:.3f}", fontsize=10, loc="left") axes[-1].set_xlabel("Bar Index", fontsize=9) path = str(Path(output_dir) / "forex_factors_per_symbol.png") fig.savefig(path, bbox_inches="tight") plt.close(fig) print(f" 分品种图已保存 → {path}") def main(): offline = "--offline" in sys.argv # ── 1. 加载因子 ───────────────────────────────────────────────── print(f"\n{'='*72}") print(f" Forex 组已训练因子回测分析 | offline={offline}") print(f"{'='*72}\n") candidates = [] for name, label in [ ("best_forex.json", "当前训练(score=0.485)"), ("best_forex_rank2.json", "rank2(历史)"), ("best_forex_rank3.json", "rank3(历史)"), ]: path = Path("strategies") / name loaded = load_formula(path) if loaded is None: print(f" [缺失] {name}") continue formula, readable, score = loaded candidates.append({"label": label, "file": name, "formula": formula, "readable": readable, "score": score}) print(f" {label}: {readable}") if not candidates: print("[ERROR] 没有找到任何因子文件"); sys.exit(1) print() # ── 2. 加载数据(forex 组独立加载,全量历史)──────────────────── print("加载数据(forex 组,全量历史)...") # 临时只加载 forex 组品种 original_symbols = Config.SYMBOLS[:] Config.SYMBOLS = FOREX_SYMS try: with MT5DataFetcher(offline=offline) as fetcher: mgr = MT5DataManager(fetcher) mgr.load() raw_dict = mgr.raw_dict syms = mgr.symbols T = raw_dict["open"].shape[1] times_all = raw_dict.get("time", None) print(f" 品种: {syms} T={T} bars") print(f" 时间范围: {int(times_all[0].min())} .. {int(times_all[0].max())}\n") feat = MT5FeatureEngineer.compute_features(raw_dict) # [N, F, T] target_ret = mgr.target_ret # ── 3. 逐因子回测 ─────────────────────────────────────── results = [] for c in candidates: # backtest_one 内部硬编码了 sym_names = ["EURUSD", "USDJPY"][:N] res = backtest_one(c["formula"], feat, raw_dict, target_ret, cost_rate=0.0001) if res is None: print(f" [失败] {c['label']}: 公式执行错误") continue # 补充组合资金序列 port_pnl = np.mean([d["pnl"] for d in res["per_sym"].values()], axis=0) port_cum = np.cumsum(port_pnl) res["port_pnl"] = port_pnl res["port_cum"] = port_cum res["label"] = c["label"] res["score"] = c["score"] res["readable"] = c["readable"] results.append(res) finally: Config.SYMBOLS = original_symbols if not results: print("[ERROR] 所有因子回测失败"); sys.exit(1) # ── 4. 对比汇总表 ─────────────────────────────────────────────── print(f"{'='*72}") print(f" 回测对比汇总({T} bars H1,EURUSD+USDJPY 等权组合)") print(f"{'='*72}") hdr = f" {'因子':22s} {'Score':>7} {'TotRet':>8} {'Sharpe':>7} {'Sortino':>8} {'MDD':>7} {'IC':>7} {'H1':>5} {'H2':>5} {'AvgHold':>8}" print(hdr) print(f" {'─'*88}") for r in results: h1, h2 = r["half1_sharpe"], r["half2_sharpe"] consistency = "✓" if h1 > 0 and h2 > 0 else ("⚠" if h1 * h2 > 0 else "✗") print(f" {r['label']:22s} " f"{r['score']:>7.3f} " f"{r['port_total_ret']:>+8.3f} " f"{r['port_sharpe']:>+7.3f} " f"{r['port_sortino']:>+8.3f} " f"{r['port_mdd']:>7.3f} " f"{r['ic']:>7.4f} " f"{h1:>+5.2f} {h2:>+5.2f} " f"{r['avg_hold_h']:>7.1f}h {consistency}") print() # ── 5. 品种级详情 ─────────────────────────────────────────────── print(f"{'─'*72}") print(f" 品种级详情") print(f"{'─'*72}") for r in results: print(f"\n [{r['label']}] {r['readable']}") for sym, d in r["per_sym"].items(): sig = "✓" if d["total_ret"] > 0 else "✗" print(f" {sym:8s}: TotRet={d['total_ret']:+.3f} " f"Sharpe={d['sharpe']:+.3f} Sortino={d['sortino']:+.3f} " f"MDD={d['mdd']:.3f} Trades={d['n_trades']} " f"AvgHold={d['avg_hold']:.0f}h {sig}") # ── 6. 过拟合诊断 ─────────────────────────────────────────────── print(f"\n{'='*72}") print(f" 过拟合与稳健性诊断") print(f"{'='*72}") for r in results: issues = [] h1, h2 = r["half1_sharpe"], r["half2_sharpe"] if h1 > 0 and h2 < 0: issues.append(f"前半 Sharpe={h1:.2f} > 0,后半={h2:.2f} < 0 → 过拟合嫌疑") if h1 < 0 and h2 > 0: issues.append(f"前半={h1:.2f} < 0,后半={h2:.2f} > 0 → 近期才生效,需警惕") if r["port_mdd"] > 0.5: issues.append(f"最大回撤过大: {r['port_mdd']:.3f}") if r["avg_hold_h"] < 2: issues.append(f"持仓太短({r['avg_hold_h']:.1f}h),点差侵蚀严重") if r["avg_hold_h"] > 500: issues.append(f"持仓极长({r['avg_hold_h']:.0f}h),交易数极少,统计不可靠") if r["n_pos_syms"] < r["n_syms"]: issues.append(f"仅 {r['n_pos_syms']}/{r['n_syms']} 品种盈利,跨品种一致性差") if abs(r["ic"]) < 0.005: issues.append(f"IC≈0 ({r['ic']:.4f}),预测力存疑") # 前后半段 Sortino 一致性 s1, s2 = r["half1_sortino"], r["half2_sortino"] if s1 > 0 and s2 > 0 and abs(s1 - s2) > max(s1, s2) * 0.7: issues.append(f"前后半 Sortino 差异大: {s1:.2f} vs {s2:.2f},稳定性一般") if issues: print(f"\n [{r['label']}]") for iss in issues: print(f" ⚠ {iss}") else: print(f"\n [{r['label']}] ✓ 无明显问题") # ── 7. 资金曲线图 ─────────────────────────────────────────────── print(f"\n{'='*72}") print(f" 生成图表") print(f"{'='*72}") times_np = times_all[0].numpy() if times_all is not None else None plot_equity_curves(results, times_np, OUTPUT_DIR) plot_per_symbol(results, OUTPUT_DIR) # ── 8. JSON 报告 ──────────────────────────────────────────────── report = { "symbols": FOREX_SYMS, "T_bars": T, "factors": [], } for r in results: report["factors"].append({ "label": r["label"], "formula": r["formula"], "readable": r["readable"], "train_score": round(r["score"], 4), "port_total_ret": round(r["port_total_ret"], 4), "port_sharpe": round(r["port_sharpe"], 4), "port_sortino": round(r["port_sortino"], 4), "port_mdd": round(r["port_mdd"], 4), "ic": round(r["ic"], 5), "half1_sharpe": round(r["half1_sharpe"], 4), "half2_sharpe": round(r["half2_sharpe"], 4), "half1_sortino": round(r["half1_sortino"], 4), "half2_sortino": round(r["half2_sortino"], 4), "avg_hold_h": round(r["avg_hold_h"], 2), "per_sym": { sym: {k: (round(v, 4) if isinstance(v, float) else v) for k, v in d.items() if k != "pnl" and k != "cum"} for sym, d in r["per_sym"].items() }, }) rp = f"{OUTPUT_DIR}/forex_factors_report.json" Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True) with open(rp, "w") as f: json.dump(report, f, indent=2, ensure_ascii=False) print(f"\n JSON 报告已保存 → {rp}") print("完成。\n") if __name__ == "__main__": main()