426 lines
16 KiB
Python
426 lines
16 KiB
Python
"""
|
||
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()
|