Files
AlphaMaster/generate_factor_equity_curves.py
T
2026-07-08 10:59:05 +08:00

408 lines
14 KiB
Python

"""
generate_factor_equity_curves.py — 各品种 best_{symbol}.json 因子深度回测 + 资金曲线
数据:D:\\K线数据 离线 H1(可用 --mt5 连 MT5 增量更新)
输出:backtest_output/equity_{symbol}.png
backtest_output/all_factors_equity.png
backtest_output/factor_equity_summary.json
"""
from __future__ import annotations
import json
import math
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.gridspec as gridspec
import matplotlib.pyplot as plt
import numpy as np
import torch
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.features import MT5FeatureEngineer
from model_core.vocab import FORMULA_VOCAB, VOCAB_VERSION
from model_core.vm import StackVM
from strategy_manager.signal import compute_target_positions_stateless
_H1_PER_YEAR = 6240
OUTPUT_DIR = Path("backtest_output")
SKIP_STEMS = {"index", "precious_metals", "forex", "metals_comm"}
COST = {"metals": 0.00020, "index": 0.00030, "forex": 0.00015}
def decode(tokens: list[int]) -> str:
names = FORMULA_VOCAB.token_names
return " -> ".join(names[t] if 0 <= t < len(names) else f"?{t}" for t in tokens)
def calc_sharpe(pnl: np.ndarray) -> float:
m, s = pnl.mean(), pnl.std()
return float(m / (s + 1e-10) * math.sqrt(_H1_PER_YEAR))
def calc_sortino(pnl: np.ndarray) -> float:
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: np.ndarray) -> float:
peak = np.maximum.accumulate(cum)
return float((peak - cum).max())
def calc_annual_return(total_ret: float, T: int) -> float:
return float(total_ret / (T / _H1_PER_YEAR)) if T > 0 else 0.0
def _cost(sym: str) -> float:
if sym.startswith("XAU") or sym.startswith("XAG"):
return COST["metals"]
if ".cash" in sym or sym.startswith("US") or sym.startswith("JP"):
return COST["index"]
return COST["forex"]
def _safe_filename(sym: str) -> str:
return re.sub(r"[^\w.\-]", "_", sym)
def load_best(path: Path) -> dict | None:
data = json.loads(path.read_text(encoding="utf-8"))
if data.get("vocab_version") != VOCAB_VERSION:
return None
formula = data.get("formula") or data.get("formula_tokens")
if not formula:
return None
sym = data.get("symbol")
if not sym:
m = re.match(r"best_(.+)\.json", path.name)
sym = m.group(1) if m else None
if not sym or sym in SKIP_STEMS:
return None
tokens = [int(t) for t in formula]
return {
"symbol": sym,
"formula": tokens,
"path": path.name,
"readable": data.get("formula_readable") or decode(tokens),
"best_score": data.get("best_score"),
"live_trading": data.get("live_trading"),
"status": data.get("status"),
"disabled_reason": data.get("disabled_reason"),
}
def solo_backtest(
formula: list[int],
raw_dict: dict,
target_ret: torch.Tensor,
symbol: str,
cost_rate: float,
) -> dict | None:
feat = MT5FeatureEngineer.compute_features(raw_dict)
vm = StackVM()
factor = vm.execute(formula, feat)
if factor is None:
return None
pos = compute_target_positions_stateless(factor)
pos_np = pos.detach().cpu().numpy().squeeze(0)
target_np = target_ret.detach().cpu().numpy().squeeze(0)
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
T = len(pnl)
cum = np.cumsum(pnl)
half = T // 2
p1, p2 = pnl[:half], pnl[half:]
years_n = max(1, T // _H1_PER_YEAR)
yearly_ret = [float(pnl[y * _H1_PER_YEAR:min((y + 1) * _H1_PER_YEAR, T)].sum()) for y in range(years_n)]
total_ret = float(cum[-1])
mdd = calc_mdd(cum)
ann = calc_annual_return(total_ret, T)
return {
"T": T,
"years_span": T / _H1_PER_YEAR,
"readable": decode(formula),
"pnl": pnl,
"cum": cum,
"total_ret": total_ret,
"ann_ret": ann,
"sharpe": calc_sharpe(pnl),
"sortino": calc_sortino(pnl),
"mdd": mdd,
"calmar": ann / (mdd + 1e-8),
"half1_ret": float(np.cumsum(p1)[-1]) if len(p1) else 0.0,
"half2_ret": float(np.cumsum(p2)[-1]) if len(p2) else 0.0,
"half1_sharpe": calc_sharpe(p1) if len(p1) else 0.0,
"half2_sharpe": calc_sharpe(p2) if len(p2) else 0.0,
"yearly_ret": yearly_ret,
"per_sym": {symbol: {"pnl": pnl, "cum": cum, "ann_ret": ann, "sharpe": calc_sharpe(pnl), "mdd": mdd}},
}
def _date_labels(times: np.ndarray | None, T: int) -> tuple[list[int], list[str]] | None:
if times is None or len(times) != T:
return None
step = max(1, T // 10)
ticks = list(range(0, T, step))
labels = [
datetime.fromtimestamp(int(times[i]), tz=timezone.utc).strftime("%Y-%m-%d")
for i in ticks
]
return ticks, labels
def plot_solo_equity(
symbol: str,
result: dict,
times: np.ndarray | None,
cost_rate: float,
meta: dict,
output_dir: Path,
) -> str:
output_dir.mkdir(parents=True, exist_ok=True)
pnl = result["pnl"]
cum = result["cum"]
T = len(pnl)
x = np.arange(T)
fig = plt.figure(figsize=(16, 10), dpi=110)
gs = gridspec.GridSpec(3, 2, height_ratios=[2.8, 1.0, 1.2], hspace=0.22, wspace=0.18)
ax_eq = fig.add_subplot(gs[0, :])
ax_dd = fig.add_subplot(gs[1, :], sharex=ax_eq)
ax_yr = fig.add_subplot(gs[2, 0])
ax_roll = fig.add_subplot(gs[2, 1])
ann, sharpe, mdd = result["ann_ret"], result["sharpe"], result["mdd"]
ax_eq.plot(x, cum, linewidth=2.0, color="#1565c0",
label=f"{symbol} Ann={ann*100:+.2f}% Sharpe={sharpe:+.2f} MDD={mdd*100:.2f}%")
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)
status_note = " [DISABLED LIVE]" if meta.get("status") == "disabled_live" else ""
ax_eq.set_title(
f"{symbol} Solo Factor Backtest{status_note}\n"
f"Formula: {result['readable']} | cost={cost_rate} | "
f"T={T} bars ({result['years_span']:.2f}y) | score={meta.get('best_score', 0):.4f}",
fontsize=10, pad=8,
)
peak = np.maximum.accumulate(cum)
dd = 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)
yearly = result.get("yearly_ret") or []
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.85)
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)
window = 250
if T > window:
roll = []
for i in range(window, T):
seg = pnl[i - window:i]
m, s = seg.mean(), seg.std()
roll.append(m / (s + 1e-10) * math.sqrt(_H1_PER_YEAR))
ax_roll.plot(range(window, T), roll, linewidth=0.9, color="#2e7d32", alpha=0.75)
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", fontsize=8)
ax_roll.set_title(f"Rolling Sharpe ({window} bars)", fontsize=9)
ax_roll.grid(alpha=0.2)
dt = _date_labels(times, T)
if dt:
ticks, labels = dt
ax_dd.set_xticks(ticks)
ax_dd.set_xticklabels(labels, fontsize=7, rotation=20)
plt.setp(ax_eq.get_xticklabels(), visible=False)
out = output_dir / f"equity_{_safe_filename(symbol)}.png"
fig.savefig(out, bbox_inches="tight")
plt.close(fig)
return str(out)
def plot_combined(results_map: dict, output_dir: Path) -> str:
output_dir.mkdir(parents=True, exist_ok=True)
syms = list(results_map.keys())
if not syms:
return ""
# 对齐到最短序列(各品种历史长度不同)
T = min(len(results_map[s]["pnl"]) for s in syms)
fig = plt.figure(figsize=(18, 10), 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", "#00897b", "#e65100", "#6a1b9a", "#558b2f", "#b71c1c", "#26418f", "#795548"]
all_pnls = []
x = np.arange(T)
for i, sym in enumerate(syms):
pnl = results_map[sym]["pnl"][-T:]
cum = np.cumsum(pnl)
all_pnls.append(pnl)
ann = calc_annual_return(float(cum[-1]), T)
ax_eq.plot(x, cum, linewidth=1.0, alpha=0.75, color=colors[i % len(colors)],
label=f"{sym} ({ann*100:+.1f}%)")
port_pnl = np.stack(all_pnls, axis=0).mean(axis=0)
port_cum = np.cumsum(port_pnl)
x = np.arange(T)
ax_eq.plot(x, port_cum, linewidth=2.4, color="black",
label=f"Equal-Weight ({calc_annual_return(float(port_cum[-1]), T)*100:+.1f}%)")
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=8, ncol=2, framealpha=0.85)
ax_eq.grid(alpha=0.25)
ax_eq.set_title(
f"All Factor Strategies — Solo Equity Curves (aligned last {T} H1 bars) | "
f"Portfolio Sharpe={calc_sharpe(port_pnl):+.2f} MDD={calc_mdd(port_cum)*100:.2f}%",
fontsize=11, pad=8,
)
peak = np.maximum.accumulate(port_cum)
dd = port_cum - peak
ax_dd.fill_between(x, dd, 0, alpha=0.45, color="#b71c1c")
ax_dd.axhline(0, color="gray", linewidth=0.5)
ax_dd.set_ylabel("Drawdown", fontsize=9)
ax_dd.set_xlabel("Bar Index (H1)", fontsize=9)
ax_dd.grid(alpha=0.2)
out = output_dir / "all_factors_equity.png"
fig.savefig(out, bbox_inches="tight")
plt.close(fig)
return str(out)
def run_solo(symbol: str, formula: list[int], fetcher: MT5DataFetcher) -> tuple[dict | None, np.ndarray | None]:
cost = _cost(symbol)
orig = Config.SYMBOLS[:]
Config.SYMBOLS = [symbol]
try:
mgr = MT5DataManager(fetcher)
mgr.load()
if symbol not in mgr.symbols:
return None, None
times_t = mgr.raw_dict.get("time")
times = times_t[0].detach().cpu().numpy() if times_t is not None else None
result = solo_backtest(formula, mgr.raw_dict, mgr.target_ret, symbol, cost)
return result, times
finally:
Config.SYMBOLS = orig
def main():
offline = "--mt5" not in sys.argv
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
loaded_list = [x for p in sorted(Path("strategies").glob("best_*.json")) if (x := load_best(p))]
print(f"\nFactor Equity Curves | vocab={VOCAB_VERSION} | "
f"{'offline D:/K线数据' if offline else 'MT5+缓存'}\n")
print(f"{'品种':<16} {'年化%':>8} {'Sharpe':>8} {'MDD%':>8}")
print("-" * 50)
summary_rows = []
curve_data: dict[str, dict] = {}
with MT5DataFetcher(offline=offline) as fetcher:
if not offline:
fetcher.connect()
for meta in loaded_list:
sym = meta["symbol"]
result, times = run_solo(sym, meta["formula"], fetcher)
if result is None:
print(f"{sym:<16} SKIP (no data)")
continue
plot_path = plot_solo_equity(sym, result, times, _cost(sym), meta, OUTPUT_DIR)
print(f"{sym:<16} {result['ann_ret']*100:>8.2f} {result['sharpe']:>8.3f} "
f"{result['mdd']*100:>8.2f}")
curve_data[sym] = {
"pnl": result["pnl"],
"cum": result["cum"],
"ann_ret": result["ann_ret"],
}
summary_rows.append({
"symbol": sym,
"strategy_file": meta["path"],
"formula": meta["formula"],
"formula_readable": meta["readable"],
"best_score": meta.get("best_score"),
"status": meta.get("status"),
"live_trading": meta.get("live_trading"),
"disabled_reason": meta.get("disabled_reason"),
"cost_rate": _cost(sym),
"T": result["T"],
"years": round(result["years_span"], 2),
"total_ret": round(result["total_ret"], 6),
"ann_ret": round(result["ann_ret"], 6),
"sharpe": round(result["sharpe"], 4),
"sortino": round(result["sortino"], 4),
"mdd": round(result["mdd"], 6),
"calmar": round(result["calmar"], 4),
"half1_ret": round(result["half1_ret"], 6),
"half2_ret": round(result["half2_ret"], 6),
"half1_sharpe": round(result["half1_sharpe"], 4),
"half2_sharpe": round(result["half2_sharpe"], 4),
"yearly_ret": [round(v, 6) for v in result["yearly_ret"]],
"equity_chart": plot_path,
"valid_ann_positive": result["ann_ret"] > 0,
})
if curve_data:
combined = plot_combined(curve_data, OUTPUT_DIR)
print(f"\n组合图 -> {combined}")
summary = {
"generated_at": datetime.now(tz=timezone.utc).isoformat(),
"vocab_version": VOCAB_VERSION,
"data_mode": "offline" if offline else "mt5",
"symbols": summary_rows,
"valid_count": sum(1 for r in summary_rows if r["valid_ann_positive"]),
"total_count": len(summary_rows),
}
json_path = OUTPUT_DIR / "factor_equity_summary.json"
json_path.write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"摘要 JSON -> {json_path}")
print(f"单品种图 -> {OUTPUT_DIR}/equity_*.png")
if __name__ == "__main__":
main()