Files
AlphaMaster/backtest_index_best.py
T

426 lines
16 KiB
Python
Raw 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_index_best.py — 对 index 组最优因子做完整回测验证
用法:
python backtest_index_best.py --offline
加载 strategies/best_index.json 中的因子,在 index 组 5 个品种全量历史数据上回测,
输出:回测摘要、品种级详情、前后半段一致性诊断、资金曲线图。
"""
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.vm import StackVM
from model_core.features import MT5FeatureEngineer
from strategy_manager.signal import compute_target_positions_stateless
_H1_PER_YEAR = 6240
OUTPUT_DIR = "backtest_output"
INDEX_SYMS = Config.SYMBOL_GROUPS["index"] # ["US30.cash", "US100.cash", "US500.cash", "US2000.cash", "JP225.cash"]
# 指数品种点差成本更高
COST_RATE_INDEX = 0.0003
def decode(toks):
names = FORMULA_VOCAB.token_names
return " -> ".join(names[t] if 0 <= t < len(names) else f"?{t}" for t in toks)
def calc_sharpe(pnl):
m, s = pnl.mean(), pnl.std()
return float(m / (s + 1e-10) * math.sqrt(_H1_PER_YEAR))
def calc_sortino(pnl):
m = pnl.mean()
down = pnl[pnl < 0]
ds = down.std() if len(down) > 0 else 1e-10
ds = max(ds, abs(m), 1e-10)
return float(np.clip(m / ds * math.sqrt(_H1_PER_YEAR), -20, 20))
def calc_mdd(cum_pnl):
peak = np.maximum.accumulate(cum_pnl)
return float((peak - cum_pnl).max())
def calc_ic(factor, target_ret):
"""时序 IC:factor[t] vs target_ret[t+1],逐品种再取均值。"""
N, T = factor.shape
ic_list = []
for n in range(N):
x = factor[n, :-1]
y = target_ret[n, 1:]
xm = x - x.mean()
ym = y - y.mean()
sx = np.sqrt((xm**2).mean())
sy = np.sqrt((ym**2).mean())
if sx < 1e-6 or sy < 1e-6:
continue
ic = (xm * ym).mean() / (sx * sy + 1e-8)
ic_list.append(float(ic))
return float(np.mean(ic_list)) if ic_list else 0.0
def calc_annual_return(total_ret, T):
"""年化收益率:total_ret / (T / H1_PER_YEAR)"""
years = T / _H1_PER_YEAR
return float(total_ret / years) if years > 0 else 0.0
def calc_calmar(total_ret, mdd, T):
"""Calmar = 年化收益 / MDD"""
ann = calc_annual_return(total_ret, T)
return float(ann / mdd) if mdd > 1e-8 else 0.0
def backtest_one(formula, feat, raw_dict, target_ret, cost_rate=COST_RATE_INDEX):
"""对一个公式跑完整回测,返回统计结果。"""
vm = StackVM()
factor = vm.execute(formula, feat) # [N, T]
if factor is None:
return None
N, T = factor.shape
factor_np = factor.detach().numpy()
target_np = target_ret.detach().numpy()
# 持仓
pos = compute_target_positions_stateless(factor) # [N, T]
pos_np = pos.detach().numpy()
# 换手
prev = np.zeros_like(pos_np)
prev[:, 1:] = pos_np[:, :-1]
turnover = np.abs(pos_np - prev)
pnl = pos_np * target_np - turnover * cost_rate # [N, T]
# 分品种统计
per_sym = {}
for i, sym in enumerate(INDEX_SYMS[:N]):
p = pnl[i]
cum = np.cumsum(p)
n_trades = int((np.abs(np.diff(pos_np[i])) > 0.5).sum())
per_sym[sym] = {
"pnl": p,
"cum": cum,
"total_ret": float(cum[-1]),
"ann_ret": calc_annual_return(float(cum[-1]), T),
"sharpe": calc_sharpe(p),
"sortino": calc_sortino(p),
"mdd": calc_mdd(cum),
"n_trades": n_trades,
"avg_hold": float(T / max(1, n_trades)) if n_trades > 0 else float(T),
"ic": calc_ic(factor_np[i:i+1], target_np[i:i+1]),
}
# 组合(等权)
port_pnl = pnl.mean(axis=0)
port_cum = np.cumsum(port_pnl)
# IC
ic = calc_ic(factor_np, target_np)
# 分段分析:前50% vs 后50%(检查过拟合)
split = T // 2
p1 = port_pnl[:split]
p2 = port_pnl[split:]
port_total_ret = float(port_cum[-1])
port_mdd = calc_mdd(port_cum)
port_ann_ret = calc_annual_return(port_total_ret, T)
port_calmar = calc_calmar(port_total_ret, port_mdd, T)
return {
"formula": formula,
"readable": decode(formula),
"per_sym": per_sym,
"port_total_ret": port_total_ret,
"port_ann_ret": port_ann_ret,
"port_sharpe": calc_sharpe(port_pnl),
"port_sortino": calc_sortino(port_pnl),
"port_mdd": port_mdd,
"port_calmar": port_calmar,
"ic": ic,
"n_pos_syms": sum(1 for d in per_sym.values() if d["total_ret"] > 0),
"n_syms": N,
# 稳定性:前后半段
"half1_sharpe": calc_sharpe(p1),
"half2_sharpe": calc_sharpe(p2),
"half1_sortino": calc_sortino(p1),
"half2_sortino": calc_sortino(p2),
"half1_ret": float(np.cumsum(p1)[-1]),
"half2_ret": float(np.cumsum(p2)[-1]),
# 换手
"avg_turnover": float(turnover.mean()),
"avg_hold_h": float(np.mean([d["avg_hold"] for d in per_sym.values()])),
# FTMO 相关
"max_consec_loss": _max_consecutive_loss(port_pnl),
}
def _max_consecutive_loss(pnl):
"""最大连续亏损 bar 数"""
max_streak = 0
current = 0
for p in pnl:
if p < 0:
current += 1
max_streak = max(max_streak, current)
else:
current = 0
return max_streak
def plot_equity_curves(result, times_arr, output_dir):
"""绘制资金曲线 + 回撤 + 各品种详情"""
Path(output_dir).mkdir(parents=True, exist_ok=True)
fig = plt.figure(figsize=(18, 12), dpi=110)
gs = gridspec.GridSpec(3, 1, height_ratios=[3, 1, 2], hspace=0.15)
ax_eq = fig.add_subplot(gs[0])
ax_dd = fig.add_subplot(gs[1], sharex=ax_eq)
ax_sym = fig.add_subplot(gs[2], sharex=ax_eq)
T = len(result["port_pnl"]) if "port_pnl" in result else len(result["per_sym"][INDEX_SYMS[0]]["pnl"])
x = np.arange(T)
# 组合资金曲线
port_cum = np.cumsum(result.get("port_pnl", np.zeros(T)))
ax_eq.plot(x, port_cum, linewidth=2.0, color="#1565c0",
label=f"Portfolio (AnnRet={result['port_ann_ret']:+.4f}, Sortino={result['port_sortino']:+.2f}, MDD={result['port_mdd']:.3f})")
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(f"Index 组最优因子回测 | {' + '.join(INDEX_SYMS)} 等权组合\n"
f"公式: {result['readable']}", fontsize=11, pad=8)
# 回撤
peak = np.maximum.accumulate(port_cum)
dd = port_cum - peak
ax_dd.fill_between(x, dd, 0, alpha=0.4, color="#1565c0", label="Drawdown")
ax_dd.axhline(0, color="gray", linewidth=0.5)
ax_dd.set_ylabel("Drawdown", fontsize=9)
ax_dd.grid(alpha=0.2)
ax_dd.legend(loc="lower left", fontsize=8)
# 各品种资金曲线
colors = ["#e65100", "#00897b", "#6a1b9a", "#b71c1c", "#26418f"]
for i, sym in enumerate(INDEX_SYMS):
if sym in result["per_sym"]:
cum = result["per_sym"][sym]["cum"]
c = colors[i % len(colors)]
ax_sym.plot(x, cum, linewidth=1.2, color=c, alpha=0.8,
label=f"{sym} (Ret={result['per_sym'][sym]['total_ret']:+.3f})")
ax_sym.axhline(0, color="gray", linewidth=0.5, linestyle="--")
ax_sym.set_ylabel("Per-Symbol Cum Return", fontsize=9)
ax_sym.set_xlabel("Bar Index (H1)", fontsize=9)
ax_sym.legend(loc="upper left", fontsize=7, ncol=3, framealpha=0.85)
ax_sym.grid(alpha=0.2)
plt.tight_layout()
plot_path = Path(output_dir) / "index_best_backtest.png"
fig.savefig(plot_path, bbox_inches="tight")
plt.close(fig)
return str(plot_path)
def main():
offline = "--offline" in sys.argv
# ── 加载最优因子
strategy_path = Path("strategies/best_index.json")
if not strategy_path.exists():
print(f"❌ 策略文件不存在: {strategy_path}")
return
data = json.load(open(strategy_path))
if data.get("vocab_version", "unknown") != VOCAB_VERSION:
print(f"❌ 词表版本不匹配: {data.get('vocab_version')} != {VOCAB_VERSION}")
return
formula = data["formula"]
best_score = data.get("best_score", 0.0)
print(f"\n{'='*70}")
print(f" Index 组最优因子回测验证")
print(f"{'='*70}")
print(f" 公式 tokens : {formula}")
print(f" 可读形式 : {decode(formula)}")
print(f" 训练评分 : {best_score:.4f}")
print(f" 词表版本 : {VOCAB_VERSION}")
print(f" 品种 : {INDEX_SYMS}")
print(f" 成本率 : {COST_RATE_INDEX}")
print(f" offline : {offline}")
print(f"{'='*70}\n")
# ── 加载数据
print("加载数据...")
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]
print(f" 全部品种: {syms}")
print(f" T={T} bars (H1)")
# 取 index 组品种
idx_idx = [syms.index(s) for s in INDEX_SYMS if s in syms]
if len(idx_idx) < len(INDEX_SYMS):
missing = set(INDEX_SYMS) - set(syms)
print(f" [WARN] 缺失品种: {missing}")
idx_raw = {k: v[idx_idx] for k, v in raw_dict.items()}
idx_feat = MT5FeatureEngineer.compute_features(idx_raw)
target_ret = mgr.target_ret[idx_idx]
idx_syms_loaded = [syms[i] for i in idx_idx]
print(f" Index 品种: {idx_syms_loaded} feat={idx_feat.shape}\n")
# ── 执行回测
result = backtest_one(formula, idx_feat, idx_raw, target_ret)
if result is None:
print("❌ 因子执行失败!")
return
# 补充 port_pnl 用于画图
port_pnl = np.mean([result["per_sym"][s]["pnl"] for s in idx_syms_loaded if s in result["per_sym"]], axis=0)
result["port_pnl"] = port_pnl
# ── 打印回测摘要
print(f"\n{'='*70}")
print(f" 回测结果摘要({T} bars H1,index 组 {len(idx_syms_loaded)} 品种)")
print(f"{'='*70}")
print(f" 公式: {result['readable']}")
print(f" ─────────────────────────────────────────")
print(f" 组合总收益: {result['port_total_ret']:+.4f}")
print(f" 年化收益: {result['port_ann_ret']:+.4f}")
print(f" Sharpe: {result['port_sharpe']:+.4f}")
print(f" Sortino: {result['port_sortino']:+.4f}")
print(f" Max DD: {result['port_mdd']:.4f}")
print(f" Calmar: {result['port_calmar']:+.4f}")
print(f" IC: {result['ic']:.5f}")
print(f" 平均换手: {result['avg_turnover']:.6f}")
print(f" 平均持仓: {result['avg_hold_h']:.1f}h")
print(f" 最大连续亏损: {result['max_consec_loss']} bars")
print(f" 盈利品种: {result['n_pos_syms']}/{result['n_syms']}")
# ── 前后半段一致性
print(f"\n ── 前后半段一致性 ─────────────────────")
print(f" 前半段: Sharpe={result['half1_sharpe']:+.4f} Sortino={result['half1_sortino']:+.4f} Ret={result['half1_ret']:+.4f}")
print(f" 后半段: Sharpe={result['half2_sharpe']:+.4f} Sortino={result['half2_sortino']:+.4f} Ret={result['half2_ret']:+.4f}")
h1, h2 = result["half1_sharpe"], result["half2_sharpe"]
if h1 > 0 and h2 > 0:
print(f" [OK] 前后半段均为正,一致性良好")
elif h1 * h2 > 0:
print(f" [WARN] 前后半段同号但均为负,因子方向可能反了")
else:
print(f" [FAIL] 前后半段符号相反,过拟合嫌疑!")
# ── 品种级详情
print(f"\n ── 品种级详情 ─────────────────────────")
for sym, d in result["per_sym"].items():
sig = "[OK]" if d["total_ret"] > 0 else "[X]"
print(f" {sym:12s}: TotRet={d['total_ret']:+.4f} AnnRet={d['ann_ret']:+.4f} "
f"Sharpe={d['sharpe']:+.4f} Sortino={d['sortino']:+.4f} "
f"MDD={d['mdd']:.4f} IC={d['ic']:.5f} "
f"Trades={d['n_trades']} AvgHold={d['avg_hold']:.0f}h {sig}")
# ── FTMO 评估
print(f"\n{'='*70}")
print(f" FTMO 适配性评估")
print(f"{'='*70}")
ftmo_issues = []
if result["port_mdd"] > 0.10:
ftmo_issues.append(f"[WARN] MDD={result['port_mdd']:.4f} > 10% FTMO Max Loss 上限")
else:
print(f" [OK] MDD={result['port_mdd']:.4f} < 10%,符合 FTMO Max Loss 约束")
if result["port_ann_ret"] < 0:
ftmo_issues.append(f"[WARN] 年化收益为负,不满足 FTMO 盈利要求")
else:
print(f" [OK] 年化收益={result['port_ann_ret']:+.4f},方向正确")
if result["max_consec_loss"] > 500:
ftmo_issues.append(f"[WARN] 最大连续亏损 {result['max_consec_loss']} bars 过长,可能触发 FTMO 每日亏损限制")
if result["avg_hold_h"] < 2:
ftmo_issues.append(f"[WARN] 平均持仓 {result['avg_hold_h']:.1f}h 过短,交易成本侵蚀大")
if result["n_pos_syms"] < result["n_syms"] // 2:
ftmo_issues.append(f"[WARN] 仅 {result['n_pos_syms']}/{result['n_syms']} 品种盈利,分散度不足")
if ftmo_issues:
print(f"\n 问题:")
for iss in ftmo_issues:
print(f" {iss}")
else:
print(f"\n [OK] 未发现明显 FTMO 违规风险")
# ── 画图
print(f"\n 绘制资金曲线图...")
plot_path = plot_equity_curves(result, None, OUTPUT_DIR)
print(f" 图表已保存 → {plot_path}")
# ── 保存报告
report = {
"formula": formula,
"readable": result["readable"],
"best_score": best_score,
"vocab_version": VOCAB_VERSION,
"symbols": idx_syms_loaded,
"T": T,
"cost_rate": COST_RATE_INDEX,
"port_total_ret": round(result["port_total_ret"], 6),
"port_ann_ret": round(result["port_ann_ret"], 6),
"port_sharpe": round(result["port_sharpe"], 4),
"port_sortino": round(result["port_sortino"], 4),
"port_mdd": round(result["port_mdd"], 6),
"port_calmar": round(result["port_calmar"], 4),
"ic": round(result["ic"], 6),
"avg_turnover": round(result["avg_turnover"], 8),
"avg_hold_h": round(result["avg_hold_h"], 1),
"max_consec_loss": result["max_consec_loss"],
"n_pos_syms": result["n_pos_syms"],
"n_syms": result["n_syms"],
"half1_sharpe": round(result["half1_sharpe"], 4),
"half2_sharpe": round(result["half2_sharpe"], 4),
"half1_ret": round(result["half1_ret"], 6),
"half2_ret": round(result["half2_ret"], 6),
"per_sym": {
sym: {
"total_ret": round(d["total_ret"], 6),
"ann_ret": round(d["ann_ret"], 6),
"sharpe": round(d["sharpe"], 4),
"sortino": round(d["sortino"], 4),
"mdd": round(d["mdd"], 6),
"ic": round(d["ic"], 6),
"n_trades": d["n_trades"],
"avg_hold": round(d["avg_hold"], 1),
}
for sym, d in result["per_sym"].items()
},
}
report_path = Path(OUTPUT_DIR) / "index_best_backtest.json"
with open(report_path, "w") as f:
json.dump(report, f, indent=2, ensure_ascii=False)
print(f" 报告已保存 → {report_path}")
print(f"\n{'='*70}")
print(f" 回测完成")
print(f"{'='*70}\n")
if __name__ == "__main__":
main()