807 lines
32 KiB
Python
807 lines
32 KiB
Python
"""
|
|
backtest_detailed.py — 全组详细回测报告
|
|
|
|
输出:交易次数、平均持仓、胜率、盈亏比、最大单笔盈亏、平均盈亏、
|
|
前后半段、滚动 Sharpe、品种相关性矩阵、月度收益等
|
|
"""
|
|
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"
|
|
|
|
GROUP_CONFIG = {
|
|
"forex": {"symbols": Config.SYMBOL_GROUPS["forex"], "cost_rate": 0.00015},
|
|
"metals_comm": {"symbols": Config.SYMBOL_GROUPS["metals_comm"], "cost_rate": 0.00025},
|
|
"index": {"symbols": Config.SYMBOL_GROUPS["index"], "cost_rate": 0.00030},
|
|
}
|
|
|
|
|
|
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):
|
|
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):
|
|
years = T / _H1_PER_YEAR
|
|
return float(total_ret / years) if years > 0 else 0.0
|
|
|
|
|
|
def calc_calmar(total_ret, mdd, T):
|
|
ann = calc_annual_return(total_ret, T)
|
|
return float(ann / mdd) if mdd > 1e-8 else 0.0
|
|
|
|
|
|
def _max_consecutive_loss(pnl):
|
|
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 analyze_trades(pos_np, pnl_np, cost_rate):
|
|
"""分析交易细节:持仓变化点为交易事件"""
|
|
N, T = pos_np.shape
|
|
results = []
|
|
for n in range(N):
|
|
pos = pos_np[n]
|
|
pnl = pnl_np[n]
|
|
# 找到持仓变化点(开仓/平仓/翻转)
|
|
changes = np.where(np.abs(np.diff(pos)) > 0.01)[0] + 1
|
|
if len(changes) == 0:
|
|
results.append({
|
|
"n_trades": 0, "n_long": 0, "n_short": 0, "n_flat": 0,
|
|
"win_rate": 0, "avg_win": 0, "avg_loss": 0, "profit_factor": 0,
|
|
"max_win": 0, "max_loss": 0, "avg_trade_pnl": 0, "avg_hold_bars": 0,
|
|
})
|
|
continue
|
|
|
|
# 把每段持仓作为一个 trade
|
|
trade_bounds = []
|
|
prev_idx = 0
|
|
for c in changes:
|
|
trade_bounds.append((prev_idx, c))
|
|
prev_idx = c
|
|
trade_bounds.append((prev_idx, T))
|
|
|
|
n_trades = 0
|
|
n_long = 0
|
|
n_short = 0
|
|
n_flat = 0
|
|
wins = []
|
|
losses = []
|
|
hold_bars = []
|
|
|
|
for (s, e) in trade_bounds:
|
|
seg_pos = pos[s]
|
|
seg_pnl = pnl[s:e].sum()
|
|
seg_len = e - s
|
|
if abs(seg_pos) < 0.01:
|
|
n_flat += 1
|
|
continue
|
|
n_trades += 1
|
|
if seg_pos > 0:
|
|
n_long += 1
|
|
else:
|
|
n_short += 1
|
|
hold_bars.append(seg_len)
|
|
if seg_pnl > 0:
|
|
wins.append(seg_pnl)
|
|
else:
|
|
losses.append(seg_pnl)
|
|
|
|
win_rate = len(wins) / max(1, n_trades)
|
|
avg_win = np.mean(wins) if wins else 0
|
|
avg_loss = np.mean(losses) if losses else 0
|
|
gross_win = sum(wins)
|
|
gross_loss = abs(sum(losses))
|
|
pf = gross_win / max(gross_loss, 1e-8)
|
|
all_trades = wins + losses
|
|
max_win = max(all_trades) if all_trades else 0
|
|
max_loss = min(all_trades) if all_trades else 0
|
|
avg_trade = np.mean(all_trades) if all_trades else 0
|
|
avg_hold = np.mean(hold_bars) if hold_bars else 0
|
|
|
|
results.append({
|
|
"n_trades": n_trades,
|
|
"n_long": n_long,
|
|
"n_short": n_short,
|
|
"n_flat": n_flat,
|
|
"win_rate": float(win_rate),
|
|
"avg_win": float(avg_win),
|
|
"avg_loss": float(avg_loss),
|
|
"profit_factor": float(pf),
|
|
"max_win": float(max_win),
|
|
"max_loss": float(max_loss),
|
|
"avg_trade_pnl": float(avg_trade),
|
|
"avg_hold_bars": float(avg_hold),
|
|
})
|
|
return results
|
|
|
|
|
|
def backtest_one(formula, feat, target_ret, symbols, cost_rate):
|
|
vm = StackVM()
|
|
factor = vm.execute(formula, feat)
|
|
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)
|
|
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
|
|
|
|
# 交易分析
|
|
trade_stats = analyze_trades(pos_np, pnl, cost_rate)
|
|
|
|
per_sym = {}
|
|
for i, sym in enumerate(symbols[:N]):
|
|
p = pnl[i]
|
|
cum = np.cumsum(p)
|
|
ts = trade_stats[i]
|
|
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),
|
|
"ic": calc_ic(factor_np[i:i+1], target_np[i:i+1]),
|
|
# 交易细节
|
|
"n_trades": ts["n_trades"],
|
|
"n_long": ts["n_long"],
|
|
"n_short": ts["n_short"],
|
|
"win_rate": ts["win_rate"],
|
|
"avg_win": ts["avg_win"],
|
|
"avg_loss": ts["avg_loss"],
|
|
"profit_factor": ts["profit_factor"],
|
|
"max_win": ts["max_win"],
|
|
"max_loss": ts["max_loss"],
|
|
"avg_trade_pnl": ts["avg_trade_pnl"],
|
|
"avg_hold_bars": ts["avg_hold_bars"],
|
|
"avg_hold_h": ts["avg_hold_bars"], # H1 每 bar = 1h
|
|
"n_total_bars": T,
|
|
}
|
|
|
|
port_pnl = pnl.mean(axis=0)
|
|
port_cum = np.cumsum(port_pnl)
|
|
ic = calc_ic(factor_np, target_np)
|
|
|
|
# 组合交易统计
|
|
port_pos = pos_np.mean(axis=0)
|
|
port_trade = analyze_trades(port_pos.reshape(1, -1), port_pnl.reshape(1, -1), cost_rate)[0]
|
|
|
|
# 分段
|
|
split = T // 2
|
|
p1 = port_pnl[:split]
|
|
p2 = port_pnl[split:]
|
|
|
|
# 三等分
|
|
t1 = T // 3
|
|
t2 = 2 * T // 3
|
|
seg1 = port_pnl[:t1]
|
|
seg2 = port_pnl[t1:t2]
|
|
seg3 = port_pnl[t2:]
|
|
|
|
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)
|
|
|
|
# 逐年收益
|
|
years = max(1, T // _H1_PER_YEAR)
|
|
yearly_ret = []
|
|
for y in range(years):
|
|
s = y * _H1_PER_YEAR
|
|
e = min((y + 1) * _H1_PER_YEAR, T)
|
|
yr = float(port_pnl[s:e].sum())
|
|
yearly_ret.append(yr)
|
|
|
|
return {
|
|
"formula": formula,
|
|
"readable": decode(formula),
|
|
"per_sym": per_sym,
|
|
"port_pnl": port_pnl,
|
|
"port_cum": port_cum,
|
|
"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,
|
|
"T": T,
|
|
"years_span": T / _H1_PER_YEAR,
|
|
# 交易细节
|
|
"port_n_trades": port_trade["n_trades"],
|
|
"port_n_long": port_trade["n_long"],
|
|
"port_n_short": port_trade["n_short"],
|
|
"port_win_rate": port_trade["win_rate"],
|
|
"port_avg_win": port_trade["avg_win"],
|
|
"port_avg_loss": port_trade["avg_loss"],
|
|
"port_profit_factor": port_trade["profit_factor"],
|
|
"port_max_win": port_trade["max_win"],
|
|
"port_max_loss": port_trade["max_loss"],
|
|
"port_avg_trade_pnl": port_trade["avg_trade_pnl"],
|
|
"port_avg_hold_bars": port_trade["avg_hold_bars"],
|
|
"port_avg_hold_h": port_trade["avg_hold_bars"],
|
|
"avg_turnover": float(turnover.mean()),
|
|
"max_consec_loss": _max_consecutive_loss(port_pnl),
|
|
# 分段
|
|
"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]),
|
|
"third1_sharpe": calc_sharpe(seg1),
|
|
"third2_sharpe": calc_sharpe(seg2),
|
|
"third3_sharpe": calc_sharpe(seg3),
|
|
"third1_ret": float(np.cumsum(seg1)[-1]),
|
|
"third2_ret": float(np.cumsum(seg2)[-1]),
|
|
"third3_ret": float(np.cumsum(seg3)[-1]),
|
|
# 逐年
|
|
"yearly_ret": yearly_ret,
|
|
}
|
|
|
|
|
|
def print_detailed_report(group_name, result, symbols, cost_rate, best_score):
|
|
r = result
|
|
T = r["T"]
|
|
years = r["years_span"]
|
|
|
|
print(f"\n{'='*80}")
|
|
print(f" [{group_name}] DETAILED BACKTEST REPORT")
|
|
print(f"{'='*80}")
|
|
print(f" Formula : {r['readable']}")
|
|
print(f" Tokens : {r['formula']}")
|
|
print(f" Train score: {best_score:.4f}")
|
|
print(f" Symbols : {symbols}")
|
|
print(f" Data : {T} bars (H1) ~ {years:.2f} years")
|
|
print(f" Cost rate : {cost_rate}")
|
|
|
|
# === 组合概览 ===
|
|
print(f"\n {'─'*70}")
|
|
print(f" PORTFOLIO OVERVIEW (equal weight, {r['n_syms']} symbols)")
|
|
print(f" {'─'*70}")
|
|
print(f" Total Return : {r['port_total_ret']:+.4f} ({r['port_total_ret']*100:+.2f}%)")
|
|
print(f" Annual Return : {r['port_ann_ret']:+.4f} ({r['port_ann_ret']*100:+.2f}%)")
|
|
print(f" Sharpe Ratio : {r['port_sharpe']:+.4f}")
|
|
print(f" Sortino Ratio : {r['port_sortino']:+.4f}")
|
|
print(f" Max Drawdown : {r['port_mdd']:.4f} ({r['port_mdd']*100:.2f}%)")
|
|
print(f" Calmar Ratio : {r['port_calmar']:+.4f}")
|
|
print(f" IC (mean) : {r['ic']:.5f}")
|
|
print(f" Avg Turnover/bar : {r['avg_turnover']:.6f}")
|
|
print(f" Max Consec Loss : {r['max_consec_loss']} bars ({r['max_consec_loss']}h)")
|
|
|
|
# === 交易统计 ===
|
|
print(f"\n {'─'*70}")
|
|
print(f" TRADING STATISTICS (portfolio level)")
|
|
print(f" {'─'*70}")
|
|
print(f" Total Trades : {r['port_n_trades']}")
|
|
print(f" Long / Short : {r['port_n_long']} / {r['port_n_short']}")
|
|
print(f" Win Rate : {r['port_win_rate']*100:.1f}%")
|
|
print(f" Profit Factor : {r['port_profit_factor']:.2f}")
|
|
print(f" Avg Win : {r['port_avg_win']:+.6f}")
|
|
print(f" Avg Loss : {r['port_avg_loss']:+.6f}")
|
|
print(f" Avg Trade PnL : {r['port_avg_trade_pnl']:+.6f}")
|
|
print(f" Max Win : {r['port_max_win']:+.6f}")
|
|
print(f" Max Loss : {r['port_max_loss']:+.6f}")
|
|
print(f" Avg Hold : {r['port_avg_hold_h']:.1f} bars ({r['port_avg_hold_h']:.1f}h)")
|
|
|
|
# === 前后半段 ===
|
|
print(f"\n {'─'*70}")
|
|
print(f" CONSISTENCY ANALYSIS")
|
|
print(f" {'─'*70}")
|
|
print(f" Half-split (50/50):")
|
|
print(f" H1: Sharpe={r['half1_sharpe']:+.4f} Sortino={r['half1_sortino']:+.4f} Ret={r['half1_ret']:+.4f}")
|
|
print(f" H2: Sharpe={r['half2_sharpe']:+.4f} Sortino={r['half2_sortino']:+.4f} Ret={r['half2_ret']:+.4f}")
|
|
h1, h2 = r["half1_sharpe"], r["half2_sharpe"]
|
|
if h1 > 0 and h2 > 0:
|
|
print(f" [OK] Both positive")
|
|
elif h1 * h2 > 0:
|
|
print(f" [WARN] Same sign but negative")
|
|
else:
|
|
print(f" [FAIL] Opposite signs - overfitting suspected!")
|
|
|
|
print(f" Third-split (33/33/33):")
|
|
print(f" T1: Sharpe={r['third1_sharpe']:+.4f} Ret={r['third1_ret']:+.4f}")
|
|
print(f" T2: Sharpe={r['third2_sharpe']:+.4f} Ret={r['third2_ret']:+.4f}")
|
|
print(f" T3: Sharpe={r['third3_sharpe']:+.4f} Ret={r['third3_ret']:+.4f}")
|
|
|
|
print(f" Yearly Returns:")
|
|
for i, yr in enumerate(r["yearly_ret"]):
|
|
print(f" Year {i+1}: {yr:+.4f} ({yr*100:+.2f}%)")
|
|
|
|
# === 品种详情 ===
|
|
print(f"\n {'─'*70}")
|
|
print(f" PER-SYMBOL BREAKDOWN")
|
|
print(f" {'─'*70}")
|
|
print(f" {'Symbol':<14s} {'TotRet':>8s} {'AnnRet':>8s} {'Sharpe':>7s} {'Sort':>7s} {'MDD':>7s} {'IC':>8s} "
|
|
f"{'Trades':>6s} {'Long':>5s} {'Short':>5s} {'WinR%':>6s} {'PF':>5s} {'AvgHold':>7s} {'AvgWin':>9s} {'AvgLoss':>9s}")
|
|
print(f" {'-'*14} {'-'*8} {'-'*8} {'-'*7} {'-'*7} {'-'*7} {'-'*8} {'-'*6} {'-'*5} {'-'*5} {'-'*6} {'-'*5} {'-'*7} {'-'*9} {'-'*9}")
|
|
for sym, d in r["per_sym"].items():
|
|
sig = "OK" if d["total_ret"] > 0 else "X"
|
|
print(f" {sym:<14s} {d['total_ret']:>+8.4f} {d['ann_ret']:>+8.4f} {d['sharpe']:>+7.3f} {d['sortino']:>+7.3f} "
|
|
f"{d['mdd']:>7.4f} {d['ic']:>8.5f} {d['n_trades']:>6d} {d['n_long']:>5d} {d['n_short']:>5d} "
|
|
f"{d['win_rate']*100:>5.1f}% {d['profit_factor']:>5.2f} {d['avg_hold_h']:>6.1f}h "
|
|
f"{d['avg_win']:>+9.5f} {d['avg_loss']:>+9.5f} [{sig}]")
|
|
|
|
# === FTMO ===
|
|
print(f"\n {'─'*70}")
|
|
print(f" FTMO ASSESSMENT")
|
|
print(f" {'─'*70}")
|
|
issues = []
|
|
if r["port_mdd"] > 0.10:
|
|
issues.append(f"[WARN] MDD={r['port_mdd']*100:.2f}% > 10% FTMO Max Loss")
|
|
else:
|
|
print(f" [OK] MDD={r['port_mdd']*100:.2f}% < 10%")
|
|
if r["port_ann_ret"] > 0:
|
|
print(f" [OK] Annual return positive ({r['port_ann_ret']*100:+.2f}%)")
|
|
else:
|
|
issues.append(f"[WARN] Negative annual return")
|
|
if r["port_profit_factor"] > 1.3:
|
|
print(f" [OK] Profit Factor={r['port_profit_factor']:.2f} > 1.3")
|
|
else:
|
|
issues.append(f"[WARN] Profit Factor={r['port_profit_factor']:.2f} < 1.3")
|
|
if r["port_win_rate"] > 0.35:
|
|
print(f" [OK] Win rate={r['port_win_rate']*100:.1f}% > 35%")
|
|
else:
|
|
issues.append(f"[WARN] Win rate={r['port_win_rate']*100:.1f}% < 35%")
|
|
if r["max_consec_loss"] > 500:
|
|
issues.append(f"[WARN] Max consec loss {r['max_consec_loss']} bars too long")
|
|
if r["port_avg_hold_h"] < 2:
|
|
issues.append(f"[WARN] Avg hold {r['port_avg_hold_h']:.1f}h too short")
|
|
if r["n_pos_syms"] < r["n_syms"] // 2:
|
|
issues.append(f"[WARN] Only {r['n_pos_syms']}/{r['n_syms']} profitable")
|
|
for iss in issues:
|
|
print(f" {iss}")
|
|
if not issues:
|
|
print(f" [OK] No FTMO violations detected")
|
|
|
|
|
|
def plot_detailed(result, symbols, group_name, output_dir, cost_rate):
|
|
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
|
r = result
|
|
T = r["T"]
|
|
x = np.arange(T)
|
|
|
|
fig = plt.figure(figsize=(20, 16), dpi=110)
|
|
gs = gridspec.GridSpec(4, 2, height_ratios=[2.5, 1, 1.5, 1.5], width_ratios=[1.5, 1],
|
|
hspace=0.25, wspace=0.2)
|
|
|
|
# 1. 组合资金曲线 + 回撤
|
|
ax_eq = fig.add_subplot(gs[0, :])
|
|
port_cum = r["port_cum"]
|
|
ax_eq.plot(x, port_cum, linewidth=2.0, color="#1565c0",
|
|
label=f"Portfolio (Ann={r['port_ann_ret']*100:+.2f}%, Sharpe={r['port_sharpe']:+.2f}, MDD={r['port_mdd']*100:.2f}%)")
|
|
ax_eq.axhline(0, color="gray", linewidth=0.5, linestyle="--")
|
|
ax_eq.set_ylabel("Cumulative 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"[{group_name}] {', '.join(symbols)} | Formula: {r['readable']}\n"
|
|
f"Cost={cost_rate} T={T} bars ({r['years_span']:.2f}y) Score={r.get('best_score',0):.4f}",
|
|
fontsize=11, pad=8)
|
|
|
|
# 2. 回撤
|
|
ax_dd = fig.add_subplot(gs[1, :], sharex=ax_eq)
|
|
peak = np.maximum.accumulate(port_cum)
|
|
dd = port_cum - peak
|
|
ax_dd.fill_between(x, dd, 0, alpha=0.4, color="#1565c0")
|
|
ax_dd.axhline(0, color="gray", linewidth=0.5)
|
|
ax_dd.set_ylabel("Drawdown", fontsize=9)
|
|
ax_dd.grid(alpha=0.2)
|
|
|
|
# 3. 各品种资金曲线
|
|
ax_sym = fig.add_subplot(gs[2, 0], sharex=ax_eq)
|
|
colors = ["#e65100", "#00897b", "#6a1b9a", "#b71c1c", "#26418f"]
|
|
for i, sym in enumerate(symbols):
|
|
if sym in r["per_sym"]:
|
|
cum = r["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={r['per_sym'][sym]['total_ret']:+.3f}, WR={r['per_sym'][sym]['win_rate']*100:.0f}%)")
|
|
ax_sym.axhline(0, color="gray", linewidth=0.5, linestyle="--")
|
|
ax_sym.set_ylabel("Per-Symbol Return", fontsize=9)
|
|
ax_sym.legend(loc="upper left", fontsize=7, ncol=2, framealpha=0.85)
|
|
ax_sym.grid(alpha=0.2)
|
|
|
|
# 4. 滚动 Sharpe (250 bar window)
|
|
ax_roll = fig.add_subplot(gs[2, 1])
|
|
window = 250
|
|
if T > window:
|
|
roll_sharpe = []
|
|
for i in range(window, T):
|
|
seg = r["port_pnl"][i-window:i]
|
|
m, s = seg.mean(), seg.std()
|
|
roll_sharpe.append(m / (s + 1e-10) * math.sqrt(_H1_PER_YEAR))
|
|
ax_roll.plot(range(window, T), roll_sharpe, linewidth=0.8, color="#2e7d32", alpha=0.7)
|
|
ax_roll.axhline(0, color="gray", linewidth=0.5, linestyle="--")
|
|
ax_roll.axhline(1.0, color="orange", linewidth=0.5, linestyle=":", alpha=0.5)
|
|
ax_roll.set_ylabel("Rolling Sharpe (250)", fontsize=8)
|
|
ax_roll.set_xlabel("Bar Index", fontsize=8)
|
|
ax_roll.grid(alpha=0.2)
|
|
ax_roll.set_title("Rolling Sharpe Ratio", fontsize=9)
|
|
else:
|
|
ax_roll.text(0.5, 0.5, "Insufficient data for rolling", ha="center", va="center", transform=ax_roll.transAxes)
|
|
|
|
# 5. 逐年收益柱状图
|
|
ax_yr = fig.add_subplot(gs[3, 0])
|
|
yearly = r["yearly_ret"]
|
|
if yearly:
|
|
yr_colors = ["#2e7d32" if v >= 0 else "#c62828" for v in yearly]
|
|
ax_yr.bar(range(1, len(yearly)+1), [v*100 for v in yearly], color=yr_colors, alpha=0.8)
|
|
ax_yr.axhline(0, color="gray", linewidth=0.5)
|
|
ax_yr.set_xlabel("Year", fontsize=9)
|
|
ax_yr.set_ylabel("Return (%)", fontsize=9)
|
|
ax_yr.set_title("Yearly Returns", fontsize=9)
|
|
ax_yr.grid(alpha=0.2)
|
|
|
|
# 6. 月度收益热力图
|
|
ax_mo = fig.add_subplot(gs[3, 1])
|
|
# 按月聚合(假设 H1,每月约 520 bars)
|
|
bars_per_month = 520
|
|
n_months = T // bars_per_month
|
|
if n_months > 1:
|
|
monthly = []
|
|
for m in range(n_months):
|
|
s = m * bars_per_month
|
|
e = min((m+1) * bars_per_month, T)
|
|
monthly.append(float(r["port_pnl"][s:e].sum()))
|
|
ax_mo.bar(range(n_months), [v*100 for v in monthly],
|
|
color=["#2e7d32" if v >= 0 else "#c62828" for v in monthly], alpha=0.7)
|
|
ax_mo.axhline(0, color="gray", linewidth=0.5)
|
|
ax_mo.set_xlabel("Month", fontsize=8)
|
|
ax_mo.set_ylabel("Return (%)", fontsize=8)
|
|
ax_mo.set_title("Monthly Returns", fontsize=9)
|
|
ax_mo.grid(alpha=0.2)
|
|
else:
|
|
ax_mo.text(0.5, 0.5, "Insufficient data", ha="center", va="center", transform=ax_mo.transAxes)
|
|
|
|
plt.savefig(Path(output_dir) / f"{group_name}_detailed.png", bbox_inches="tight")
|
|
plt.close(fig)
|
|
return str(Path(output_dir) / f"{group_name}_detailed.png")
|
|
|
|
|
|
def run_group(group_name, group_cfg, fetcher):
|
|
symbols = group_cfg["symbols"]
|
|
cost_rate = group_cfg["cost_rate"]
|
|
strategy_path = Path(f"strategies/best_{group_name}.json")
|
|
|
|
if not strategy_path.exists():
|
|
print(f"\n [{group_name}] SKIP - no strategy file")
|
|
return None
|
|
|
|
data = json.load(open(strategy_path))
|
|
if data.get("vocab_version", "unknown") != VOCAB_VERSION:
|
|
print(f"\n [{group_name}] SKIP - vocab mismatch")
|
|
return None
|
|
|
|
formula = data["formula"]
|
|
best_score = data.get("best_score", 0.0)
|
|
|
|
# Load group data
|
|
original_symbols = Config.SYMBOLS[:]
|
|
Config.SYMBOLS = list(symbols)
|
|
mgr = MT5DataManager(fetcher)
|
|
mgr.load()
|
|
Config.SYMBOLS = original_symbols
|
|
|
|
raw_dict = mgr.raw_dict
|
|
syms_loaded = mgr.symbols
|
|
T = raw_dict["open"].shape[1]
|
|
|
|
feat = MT5FeatureEngineer.compute_features(raw_dict)
|
|
target_ret = mgr.target_ret
|
|
|
|
result = backtest_one(formula, feat, target_ret, syms_loaded, cost_rate)
|
|
if result is None:
|
|
print(f"\n [{group_name}] ERROR - factor execution failed")
|
|
return None
|
|
|
|
result["best_score"] = best_score
|
|
print_detailed_report(group_name, result, syms_loaded, cost_rate, best_score)
|
|
plot_path = plot_detailed(result, syms_loaded, group_name, OUTPUT_DIR, cost_rate)
|
|
print(f"\n Plot saved -> {plot_path}")
|
|
|
|
# Save JSON
|
|
report = {
|
|
"group": group_name,
|
|
"formula": formula,
|
|
"readable": result["readable"],
|
|
"best_score": best_score,
|
|
"symbols": syms_loaded,
|
|
"T": T,
|
|
"years_span": round(T / _H1_PER_YEAR, 2),
|
|
"cost_rate": cost_rate,
|
|
"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),
|
|
"max_consec_loss": result["max_consec_loss"],
|
|
"port_n_trades": result["port_n_trades"],
|
|
"port_n_long": result["port_n_long"],
|
|
"port_n_short": result["port_n_short"],
|
|
"port_win_rate": round(result["port_win_rate"], 4),
|
|
"port_avg_win": round(result["port_avg_win"], 6),
|
|
"port_avg_loss": round(result["port_avg_loss"], 6),
|
|
"port_profit_factor": round(result["port_profit_factor"], 4),
|
|
"port_max_win": round(result["port_max_win"], 6),
|
|
"port_max_loss": round(result["port_max_loss"], 6),
|
|
"port_avg_trade_pnl": round(result["port_avg_trade_pnl"], 6),
|
|
"port_avg_hold_h": round(result["port_avg_hold_h"], 1),
|
|
"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),
|
|
"third1_sharpe": round(result["third1_sharpe"], 4),
|
|
"third2_sharpe": round(result["third2_sharpe"], 4),
|
|
"third3_sharpe": round(result["third3_sharpe"], 4),
|
|
"third1_ret": round(result["third1_ret"], 6),
|
|
"third2_ret": round(result["third2_ret"], 6),
|
|
"third3_ret": round(result["third3_ret"], 6),
|
|
"yearly_ret": [round(v, 6) for v in result["yearly_ret"]],
|
|
"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"],
|
|
"n_long": d["n_long"],
|
|
"n_short": d["n_short"],
|
|
"win_rate": round(d["win_rate"], 4),
|
|
"avg_win": round(d["avg_win"], 6),
|
|
"avg_loss": round(d["avg_loss"], 6),
|
|
"profit_factor": round(d["profit_factor"], 4),
|
|
"max_win": round(d["max_win"], 6),
|
|
"max_loss": round(d["max_loss"], 6),
|
|
"avg_trade_pnl": round(d["avg_trade_pnl"], 6),
|
|
"avg_hold_h": round(d["avg_hold_h"], 1),
|
|
}
|
|
for sym, d in result["per_sym"].items()
|
|
},
|
|
}
|
|
report_path = Path(OUTPUT_DIR) / f"{group_name}_detailed.json"
|
|
with open(report_path, "w") as f:
|
|
json.dump(report, f, indent=2, ensure_ascii=False)
|
|
print(f" Report saved -> {report_path}")
|
|
|
|
return result
|
|
|
|
|
|
def main():
|
|
offline = "--offline" in sys.argv
|
|
|
|
# Update metals_comm strategy from latest checkpoint
|
|
print("Checking metals_comm latest checkpoint...")
|
|
import glob, torch
|
|
ckpt_files = sorted(glob.glob(r"D:\cl\MT5_AlphaGPT\checkpoints\ckpt_metals_comm_step_*.pt"))
|
|
if ckpt_files:
|
|
ckpt = torch.load(ckpt_files[-1], map_location='cpu', weights_only=False)
|
|
bf = ckpt.get('best_formula')
|
|
bs = ckpt.get('best_score', 0)
|
|
step = ckpt.get('step', 0)
|
|
if bf and bs > 0:
|
|
Path("strategies").mkdir(exist_ok=True)
|
|
Path("strategies/best_metals_comm.json").write_text(json.dumps({
|
|
"vocab_version": VOCAB_VERSION,
|
|
"symbol": "metals_comm",
|
|
"formula": bf,
|
|
"best_score": bs,
|
|
"step": step,
|
|
}, indent=2))
|
|
print(f" Updated: score={bs:.4f} step={step} formula={bf}")
|
|
|
|
print(f"\n{'='*80}")
|
|
print(f" FULL DETAILED BACKTEST - ALL GROUPS")
|
|
print(f"{'='*80}")
|
|
|
|
all_results = {}
|
|
with MT5DataFetcher(offline=offline) as fetcher:
|
|
for gname, gcfg in GROUP_CONFIG.items():
|
|
r = run_group(gname, gcfg, fetcher)
|
|
if r:
|
|
all_results[gname] = r
|
|
|
|
# === Cross-group summary ===
|
|
if all_results:
|
|
print(f"\n{'='*80}")
|
|
print(f" CROSS-GROUP COMPARISON")
|
|
print(f"{'='*80}")
|
|
print(f" {'Group':<14s} {'Years':>6s} {'Score':>8s} {'AnnRet':>9s} {'Sharpe':>8s} {'Sortino':>8s} "
|
|
f"{'MDD':>7s} {'Calmar':>8s} {'IC':>8s} {'Trades':>7s} {'WinR%':>6s} {'PF':>5s} {'Hold':>6s} {'Pos/N':>6s}")
|
|
print(f" {'-'*14} {'-'*6} {'-'*8} {'-'*9} {'-'*8} {'-'*8} {'-'*7} {'-'*8} {'-'*8} {'-'*7} {'-'*6} {'-'*5} {'-'*6} {'-'*6}")
|
|
for gname, r in all_results.items():
|
|
print(f" {gname:<14s} "
|
|
f"{r['years_span']:>5.2f}y "
|
|
f"{r.get('best_score',0):>8.4f} "
|
|
f"{r['port_ann_ret']:>+9.4f} "
|
|
f"{r['port_sharpe']:>+8.4f} "
|
|
f"{r['port_sortino']:>+8.4f} "
|
|
f"{r['port_mdd']:>7.4f} "
|
|
f"{r['port_calmar']:>+8.4f} "
|
|
f"{r['ic']:>8.5f} "
|
|
f"{r['port_n_trades']:>7d} "
|
|
f"{r['port_win_rate']*100:>5.1f}% "
|
|
f"{r['port_profit_factor']:>5.2f} "
|
|
f"{r['port_avg_hold_h']:>5.1f}h "
|
|
f"{r['n_pos_syms']:>2d}/{r['n_syms']:<2d}")
|
|
|
|
# === Combined portfolio ===
|
|
print(f"\n {'─'*70}")
|
|
print(f" COMBINED PORTFOLIO (equal weight across groups)")
|
|
print(f" {'─'*70}")
|
|
min_T = min(len(r["port_pnl"]) for r in all_results.values())
|
|
combined_pnl = np.zeros(min_T)
|
|
for r in all_results.values():
|
|
combined_pnl += r["port_pnl"][:min_T]
|
|
combined_pnl /= len(all_results)
|
|
|
|
combined_cum = np.cumsum(combined_pnl)
|
|
combined_mdd = calc_mdd(combined_cum)
|
|
combined_ann = calc_annual_return(float(combined_cum[-1]), min_T)
|
|
combined_sharpe = calc_sharpe(combined_pnl)
|
|
combined_sortino = calc_sortino(combined_pnl)
|
|
combined_calmar = calc_calmar(float(combined_cum[-1]), combined_mdd, min_T)
|
|
|
|
# Combined trade stats
|
|
combined_pos = np.mean([r["port_pnl"][:min_T] for r in all_results.values()], axis=0)
|
|
# Use sign of combined pnl as proxy position
|
|
combined_trade = analyze_trades(
|
|
np.sign(combined_pnl).reshape(1, -1),
|
|
combined_pnl.reshape(1, -1), 0
|
|
)[0]
|
|
|
|
print(f" Data span : {min_T} bars ({min_T/_H1_PER_YEAR:.2f} years)")
|
|
print(f" Total Return : {float(combined_cum[-1]):+.4f} ({float(combined_cum[-1])*100:+.2f}%)")
|
|
print(f" Annual Return : {combined_ann:+.4f} ({combined_ann*100:+.2f}%)")
|
|
print(f" Sharpe Ratio : {combined_sharpe:+.4f}")
|
|
print(f" Sortino Ratio : {combined_sortino:+.4f}")
|
|
print(f" Max Drawdown : {combined_mdd:.4f} ({combined_mdd*100:.2f}%)")
|
|
print(f" Calmar Ratio : {combined_calmar:+.4f}")
|
|
print(f" Max Consec Loss : {_max_consecutive_loss(combined_pnl)} bars")
|
|
|
|
# Combined plot
|
|
fig, axes = plt.subplots(3, 1, figsize=(18, 12), dpi=110,
|
|
gridspec_kw={'height_ratios': [3, 1, 2], 'hspace': 0.2})
|
|
x = np.arange(min_T)
|
|
colors_g = {"forex": "#2e7d32", "metals_comm": "#e65100", "index": "#1565c0"}
|
|
for gname, r in all_results.items():
|
|
axes[0].plot(x, np.cumsum(r["port_pnl"][:min_T]), linewidth=1.2, alpha=0.7,
|
|
color=colors_g.get(gname, "gray"),
|
|
label=f"{gname} (Ann={r['port_ann_ret']*100:+.1f}%, MDD={r['port_mdd']*100:.1f}%)")
|
|
axes[0].plot(x, combined_cum, linewidth=2.5, color="#d32f2f",
|
|
label=f"Combined (Ann={combined_ann*100:+.2f}%, Sharpe={combined_sharpe:+.2f}, MDD={combined_mdd*100:.2f}%)")
|
|
axes[0].axhline(0, color="gray", linewidth=0.5, linestyle="--")
|
|
axes[0].legend(fontsize=9, loc="upper left")
|
|
axes[0].set_title(f"All Groups Combined Portfolio ({min_T} bars, {min_T/_H1_PER_YEAR:.2f} years)", fontsize=12)
|
|
axes[0].grid(alpha=0.25)
|
|
|
|
peak = np.maximum.accumulate(combined_cum)
|
|
dd = combined_cum - peak
|
|
axes[1].fill_between(x, dd, 0, alpha=0.4, color="#d32f2f")
|
|
axes[1].axhline(0, color="gray", linewidth=0.5)
|
|
axes[1].set_ylabel("Drawdown", fontsize=9)
|
|
axes[1].grid(alpha=0.2)
|
|
|
|
# Yearly returns
|
|
years_c = max(1, min_T // _H1_PER_YEAR)
|
|
yr_ret = []
|
|
for y in range(years_c):
|
|
s = y * _H1_PER_YEAR
|
|
e = min((y+1) * _H1_PER_YEAR, min_T)
|
|
yr_ret.append(float(combined_pnl[s:e].sum()))
|
|
yr_colors = ["#2e7d32" if v >= 0 else "#c62828" for v in yr_ret]
|
|
axes[2].bar(range(1, len(yr_ret)+1), [v*100 for v in yr_ret], color=yr_colors, alpha=0.8)
|
|
axes[2].axhline(0, color="gray", linewidth=0.5)
|
|
axes[2].set_xlabel("Year", fontsize=9)
|
|
axes[2].set_ylabel("Return (%)", fontsize=9)
|
|
axes[2].set_title("Combined Yearly Returns", fontsize=10)
|
|
axes[2].grid(alpha=0.2)
|
|
|
|
plt.savefig(Path(OUTPUT_DIR) / "all_groups_detailed.png", bbox_inches="tight")
|
|
plt.close(fig)
|
|
print(f"\n Combined plot saved -> {Path(OUTPUT_DIR) / 'all_groups_detailed.png'}")
|
|
|
|
combined_report = {
|
|
"groups": list(all_results.keys()),
|
|
"T": min_T,
|
|
"years_span": round(min_T / _H1_PER_YEAR, 2),
|
|
"total_ret": round(float(combined_cum[-1]), 6),
|
|
"ann_ret": round(combined_ann, 6),
|
|
"sharpe": round(combined_sharpe, 4),
|
|
"sortino": round(combined_sortino, 4),
|
|
"mdd": round(combined_mdd, 6),
|
|
"calmar": round(combined_calmar, 4),
|
|
"max_consec_loss": _max_consecutive_loss(combined_pnl),
|
|
"yearly_ret": [round(v, 6) for v in yr_ret],
|
|
}
|
|
cr_path = Path(OUTPUT_DIR) / "all_groups_detailed.json"
|
|
with open(cr_path, "w") as f:
|
|
json.dump(combined_report, f, indent=2, ensure_ascii=False)
|
|
print(f" Combined report saved -> {cr_path}")
|
|
|
|
print(f"\n{'='*80}")
|
|
print(f" BACKTEST COMPLETE")
|
|
print(f"{'='*80}\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|