Files
AlphaMaster/backtest_current.py
2026-07-05 10:18:17 +08:00

305 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()